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
| 314 | 314 | | 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) | | |
| 315 | 315 | | Build | A project's newest build, queued or building | | |
| 316 | 316 | ||
| 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 | + | ||
| 317 | 343 | ## Without Code access | |
| 318 | 344 | ||
| 319 | 345 | A member whose membership does not include Code sees Home without anything |
| 207 | 207 | | An **issue** | Someone comments on it or changes it, or a pull request linked to it moves. | | |
| 208 | 208 | | **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. | | |
| 209 | 209 | | **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). | | |
| 210 | 211 | ||
| 211 | 212 | A page refreshes a second after the last change it shows, so a burst of | |
| 212 | 213 | them is one refresh. It never refreshes while you are typing in it or have |
| 60 | 60 | --color-danger: light-dark(var(--g1t-light-danger), var(--g1t-danger)); | |
| 61 | 61 | ||
| 62 | 62 | --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; | |
| 63 | 65 | --animate-dash: dash 1.2s linear infinite; | |
| 64 | 66 | ||
| 65 | 67 | /* Menus, popovers and dialogs (components/ui): in quickly, out quicker. */ | |
| ⋯ | |||
| 138 | 140 | } | |
| 139 | 141 | } | |
| 140 | 142 | ||
| 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). */ | |
| 142 | 144 | @keyframes g1t-fresh { | |
| 143 | 145 | from { | |
| 144 | 146 | background-color: color-mix(in srgb, var(--color-accent) 18%, transparent); | |
| 145 | 147 | } | |
| 146 | 148 | } | |
| 147 | 149 | ||
| 150 | + | @keyframes g1t-arrive { | |
| 151 | + | from { | |
| 152 | + | opacity: 0; | |
| 153 | + | transform: translateY(-8px); | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | ||
| 148 | 157 | @keyframes fade-in { | |
| 149 | 158 | from { | |
| 150 | 159 | opacity: 0; | |
| 2 | 2 | * Home's parts (routes/workspace/home.tsx): Where you're needed, Start | |
| 3 | 3 | * here, what happened in the span (what people did, what landed, builds, | |
| 4 | 4 | * 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). | |
| 6 | 7 | */ | |
| 7 | 8 | import { | |
| 8 | 9 | AlertTriangle, | |
| ⋯ | |||
| 53 | 54 | whenShort, | |
| 54 | 55 | } from "../lib/home"; | |
| 55 | 56 | import { askG1t } from "../lib/ask"; | |
| 57 | + | import { useArrived } from "../lib/use-home-live"; | |
| 56 | 58 | import { cn } from "../lib/cn"; | |
| 57 | 59 | import { money } from "../lib/money"; | |
| 58 | 60 | import { CommitText } from "./commit-message"; | |
| ⋯ | |||
| 165 | 167 | /** Rows shown before the rest fold away. */ | |
| 166 | 168 | const ROWS = 8; | |
| 167 | 169 | ||
| 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 }) { | |
| 169 | 174 | 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)}> | |
| 171 | 176 | <span className="mt-0.5 shrink-0">{ICON[row.kind]}</span> | |
| 172 | 177 | <div className="min-w-0 grow"> | |
| 173 | 178 | <Link to={row.action.to} className="line-clamp-2 text-sm font-semibold text-fg hover:underline sm:block sm:truncate"> | |
| ⋯ | |||
| 202 | 207 | /** Everything waiting on you, most pressing and longest waiting first; past the first few, the rest fold away. */ | |
| 203 | 208 | export function NeedsList({ attention, now }: { attention: Attention; now: number }) { | |
| 204 | 209 | const { rows, missing } = attention; | |
| 210 | + | const arrived = useArrived(rows.map((row) => row.key)); | |
| 205 | 211 | return ( | |
| 206 | 212 | <> | |
| 207 | 213 | {rows.length === 0 ? ( | |
| ⋯ | |||
| 209 | 215 | ) : ( | |
| 210 | 216 | <ul> | |
| 211 | 217 | {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)} /> | |
| 213 | 219 | ))} | |
| 214 | 220 | </ul> | |
| 215 | 221 | )} | |
| ⋯ | |||
| 221 | 227 | </summary> | |
| 222 | 228 | <ul className="border-t border-line"> | |
| 223 | 229 | {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)} /> | |
| 225 | 231 | ))} | |
| 226 | 232 | </ul> | |
| 227 | 233 | </details> | |
| ⋯ | |||
| 565 | 571 | ); | |
| 566 | 572 | } | |
| 567 | 573 | ||
| 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 }) { | |
| 569 | 575 | return ( | |
| 570 | − | <li className={cn(rowClass, "items-start")}> | |
| 576 | + | <li className={cn(rowClass, "items-start", arrived && ARRIVED)}> | |
| 571 | 577 | <span className="mt-0.5 shrink-0">{LANDED_ICON[change.kind]}</span> | |
| 572 | 578 | <div className="min-w-0 grow"> | |
| 573 | 579 | <div className="flex min-w-0 items-center gap-1.5"> | |
| ⋯ | |||
| 608 | 614 | now, | |
| 609 | 615 | tz, | |
| 610 | 616 | byDay = false, | |
| 617 | + | scope, | |
| 611 | 618 | }: { | |
| 612 | 619 | landed: LandedChange[] | null; | |
| 613 | 620 | missing: string[]; | |
| ⋯ | |||
| 615 | 622 | now: number; | |
| 616 | 623 | tz: string | null; | |
| 617 | 624 | byDay?: boolean; | |
| 625 | + | /** The span: switching it lists other things, which are not arrivals. */ | |
| 626 | + | scope?: string; | |
| 618 | 627 | }) { | |
| 628 | + | const arrived = useArrived(landed?.map((change) => change.key) ?? [], scope); | |
| 619 | 629 | 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)} />; | |
| 621 | 631 | return ( | |
| 622 | 632 | <Card title="Landed" hint={landed && landed.length > 0 ? landedHint(landed) : undefined}> | |
| 623 | 633 | {!landed ? ( | |
| ⋯ | |||
| 713 | 723 | ); | |
| 714 | 724 | } | |
| 715 | 725 | ||
| 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); | |
| 717 | 742 | // A workspace that doesn't deploy has nothing to say here. | |
| 718 | 743 | if (!code || (deploys && deploys.length === 0)) return null; | |
| 719 | 744 | const failed = deploys?.filter((row) => row.outcome === "failed").length ?? 0; | |
| ⋯ | |||
| 725 | 750 | <Folded | |
| 726 | 751 | items={deploys} | |
| 727 | 752 | render={(row) => ( | |
| 728 | − | <li key={row.key} className={rowClass}> | |
| 753 | + | <li key={row.key} className={cn(rowClass, arrived.has(row.key) && ARRIVED)}> | |
| 729 | 754 | <Rocket size={15} className={cn("shrink-0", row.outcome === "failed" ? "text-danger" : "text-success")} /> | |
| 730 | 755 | <div className="min-w-0 grow"> | |
| 731 | 756 | <Link to={row.to} className="block truncate text-sm text-fg hover:underline"> | |
| ⋯ | |||
| 823 | 848 | }; | |
| 824 | 849 | ||
| 825 | 850 | export function RunningList({ rows, missing, now }: { rows: RunningRow[]; missing: string[]; now: number }) { | |
| 851 | + | const arrived = useArrived(rows.map((row) => row.key)); | |
| 826 | 852 | return ( | |
| 827 | 853 | <> | |
| 828 | 854 | {rows.length === 0 ? ( | |
| ⋯ | |||
| 831 | 857 | <Folded | |
| 832 | 858 | items={rows} | |
| 833 | 859 | render={(row) => ( | |
| 834 | − | <li key={row.key} className={rowClass}> | |
| 860 | + | <li key={row.key} className={cn(rowClass, arrived.has(row.key) && ARRIVED)}> | |
| 835 | 861 | <span className="shrink-0">{RUNNING_ICON[row.kind]}</span> | |
| 836 | 862 | <div className="min-w-0 grow"> | |
| 837 | 863 | <Link to={row.to} className="block truncate text-sm text-fg hover:underline"> | |
| 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 | + | }); |
| 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 | + | } |
| 229 | 229 | busy: boolean; | |
| 230 | 230 | /** A navigation or a submission is under way: the router reloads the page itself. */ | |
| 231 | 231 | 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; | |
| 232 | 234 | }; | |
| 233 | 235 | ||
| 234 | 236 | /** | |
| ⋯ | |||
| 251 | 253 | const quiet = input.now - (state.lastChangeAt ?? state.pendingSince); | |
| 252 | 254 | const waited = input.now - state.pendingSince; | |
| 253 | 255 | 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); | |
| 255 | 257 | return ms > 0 ? { action: "wait", ms } : { action: "run" }; | |
| 256 | 258 | } | |
| 257 | 259 | ||
| 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 | + | } |
| 13 | 13 | * socket is down, the page refreshes when the tab comes back into view | |
| 14 | 14 | * (at most every 30 s) and once a minute while it is in view. | |
| 15 | 15 | * | |
| 16 | + | * `useLive` is the same for a page over several repositories (Home, | |
| 17 | + | * the workspace's): one `watch_repos` asks for them all. | |
| 18 | + | * | |
| 16 | 19 | * Browser-only work happens in effects; on the server the hook does | |
| 17 | 20 | * nothing. | |
| 18 | 21 | */ | |
| ⋯ | |||
| 60 | 63 | show: () => void; | |
| 61 | 64 | }; | |
| 62 | 65 | ||
| 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 | + | ||
| 63 | 83 | // ── Watching ───────────────────────────────────────────────────────────── | |
| 64 | 84 | ||
| 65 | 85 | /** When each repository was last asked for, by `owner/name`, so pages and reconnects ask once. */ | |
| ⋯ | |||
| 67 | 87 | /** Asking again sooner than this is the same ask. */ | |
| 68 | 88 | const ASK_AGAIN_MS = 5000; | |
| 69 | 89 | ||
| 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> { | |
| 71 | 92 | 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); | |
| 75 | 99 | try { | |
| 76 | 100 | const answer = await fetch("/-/notify", { | |
| 77 | 101 | method: "POST", | |
| 78 | 102 | headers: { "content-type": "application/json", accept: "application/json" }, | |
| 79 | 103 | 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 }), | |
| 81 | 105 | }); | |
| 82 | 106 | if (!answer.ok) throw new Error(`status ${answer.status}`); | |
| 83 | 107 | const body = (await answer.json().catch(() => null)) as { ok?: unknown } | null; | |
| 84 | 108 | if (body?.ok !== true) throw new Error("refused"); | |
| 85 | 109 | return true; | |
| 86 | 110 | } catch { | |
| 87 | − | asked.delete(path); | |
| 111 | + | for (const path of due) asked.delete(path); | |
| 88 | 112 | return false; | |
| 89 | 113 | } | |
| 90 | 114 | } | |
| ⋯ | |||
| 111 | 135 | export function useRepoLive(options: RepoLiveOptions): RepoLive { | |
| 112 | 136 | const layout = useRouteLoaderData("routes/repo/layout") as LayoutData | undefined; | |
| 113 | 137 | 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(" "); | |
| 118 | 154 | ||
| 119 | 155 | const connected = useFeedConnected(); | |
| 120 | 156 | const [watching, setWatching] = useState(false); | |
| ⋯ | |||
| 145 | 181 | const check = useCallback(() => { | |
| 146 | 182 | if (timer.current) clearTimeout(timer.current); | |
| 147 | 183 | 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 }); | |
| 149 | 185 | if (decision.action === "run") run(); | |
| 150 | 186 | else if (decision.action === "wait") timer.current = setTimeout(check, decision.ms); | |
| 151 | 187 | // "hold": out of view; coming back into view (`back` below) asks again. | |
| ⋯ | |||
| 162 | 198 | [check], | |
| 163 | 199 | ); | |
| 164 | 200 | ||
| 165 | − | // Watching the repository: on opening the page, whenever the socket | |
| 201 | + | // Watching the repositories: on opening the page, whenever the socket | |
| 166 | 202 | // (re)connects, and before the watch runs out. | |
| 167 | 203 | useEffect(() => { | |
| 168 | − | if (!enabled || !signedIn || !connected || !path) { | |
| 204 | + | if (!enabled || !signedIn || !connected || !paths) { | |
| 169 | 205 | setWatching(false); | |
| 170 | 206 | return; | |
| 171 | 207 | } | |
| 172 | 208 | let cancelled = false; | |
| 173 | 209 | const ask = (force: boolean) => | |
| 174 | − | void watchRepo(path, force).then((ok) => { | |
| 210 | + | void watchRepos(paths.split(" "), force).then((ok) => { | |
| 175 | 211 | if (!cancelled) setWatching(ok); | |
| 176 | 212 | }); | |
| 177 | 213 | ask(true); | |
| ⋯ | |||
| 180 | 216 | cancelled = true; | |
| 181 | 217 | clearInterval(renew); | |
| 182 | 218 | }; | |
| 183 | − | }, [enabled, signedIn, connected, path]); | |
| 219 | + | }, [enabled, signedIn, connected, paths]); | |
| 184 | 220 | ||
| 185 | 221 | // Changes as they arrive. | |
| 186 | 222 | useEffect(() => { | |
| 187 | − | if (!enabled || !repoId) return; | |
| 223 | + | if (!enabled || !ids) return; | |
| 224 | + | const watched = new Set(ids.split(" ")); | |
| 188 | 225 | return onRepoChanges((changes) => { | |
| 189 | 226 | 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)); | |
| 191 | 228 | const holding = hold ? mine.filter((change) => hold(change)) : []; | |
| 192 | 229 | const matched = mine.filter((change) => !holding.includes(change) && match(change)); | |
| 193 | 230 | if (holding.length) setHeld((before) => [...before, ...holding]); | |
| 194 | 231 | // While something is held, the rest waits with it: shown together. | |
| 195 | 232 | if (matched.length && !holding.length && heldCount.current === 0) queue(); | |
| 196 | 233 | }); | |
| 197 | − | }, [enabled, repoId, queue]); | |
| 234 | + | }, [enabled, ids, queue]); | |
| 198 | 235 | ||
| 199 | 236 | // Another branch or page: what was held is no longer about it. | |
| 200 | 237 | useEffect(() => { | |
| ⋯ | |||
| 245 | 282 | run(); | |
| 246 | 283 | }, [run]); | |
| 247 | 284 | ||
| 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 }; | |
| 249 | 290 | } | |
| 24 | 24 | * the account by identity), or `watch_repo`, for a repository page that | |
| 25 | 25 | * updates live: its tabs then hear what changes there (`repo`, its | |
| 26 | 26 | * `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). | |
| 28 | 29 | * | |
| 29 | 30 | * The phone app calls this with its access token and no `Origin`, which | |
| 30 | 31 | * the same-origin check lets through (lib/same-origin.ts). | |
| ⋯ | |||
| 45 | 46 | preferences?: NotifyPreferencesChange; | |
| 46 | 47 | change?: PresenceChange | SoundSettingsChange; | |
| 47 | 48 | repo?: string; | |
| 49 | + | repos?: unknown; | |
| 48 | 50 | }; | |
| 49 | 51 | ||
| 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 | + | ||
| 50 | 67 | export async function action({ context, request }: Route.ActionArgs) { | |
| 51 | 68 | assertSameOrigin(request); | |
| 52 | 69 | const user = requireUser(context, request); | |
| ⋯ | |||
| 87 | 104 | return Response.json(await notify.setPresence(user, { ...change, ...("status" in change ? { status } : {}) })); | |
| 88 | 105 | } | |
| 89 | 106 | 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) }); | |
| 98 | 117 | } | |
| 99 | 118 | default: | |
| 100 | 119 | return Response.json({ ok: false }, { status: 400 }); | |
| 1 | − | import { Suspense, useEffect } from "react"; | |
| 1 | + | import { Suspense, useEffect, useState } from "react"; | |
| 2 | 2 | import { Await, data } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import { hasCodeAccess } from "@g1t/contracts"; | |
| ⋯ | |||
| 65 | 65 | reachFor, | |
| 66 | 66 | repoRefs, | |
| 67 | 67 | } from "../../lib/home.server"; | |
| 68 | + | import { HOME_WATCH_MAX, type HomeRepo } from "../../lib/home-live"; | |
| 68 | 69 | import { readCookie } from "../../lib/mission"; | |
| 70 | + | import { useHomeLive } from "../../lib/use-home-live"; | |
| 69 | 71 | import { loadOnboarding } from "../../lib/onboarding.server"; | |
| 70 | 72 | import { OnboardingOnPage } from "../../components/onboarding"; | |
| 71 | 73 | import { loadMissionControl } from "../../lib/mission-control.server"; | |
| ⋯ | |||
| 93 | 95 | * at (routes/workspace/home-seen.ts), so a refresh never wipes it. Only | |
| 94 | 96 | * the last visit (for the span) is waited for; every section streams in | |
| 95 | 97 | * 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. | |
| 96 | 100 | * Definitions: lib/home.ts and the Home guide. | |
| 97 | 101 | */ | |
| 98 | 102 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| ⋯ | |||
| 184 | 188 | })) | |
| 185 | 189 | .catch(() => ({ rows: [], missing: ["Workflows", "Deployments"] })); | |
| 186 | 190 | 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(() => []); | |
| 187 | 195 | ||
| 188 | 196 | return { | |
| 189 | 197 | slug, | |
| ⋯ | |||
| 199 | 207 | /** The sentence under the heading needs the digest, what landed and what waits. */ | |
| 200 | 208 | head: Promise.all([digest, happened, waiting]), | |
| 201 | 209 | spend, | |
| 210 | + | watch, | |
| 202 | 211 | onboarding, | |
| 203 | 212 | setupAsked, | |
| 204 | 213 | }; | |
| 205 | 214 | } | |
| 206 | 215 | ||
| 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 | + | ||
| 207 | 236 | /** How long the page must have been in view before it counts as a visit. */ | |
| 208 | 237 | const SEEN_AFTER_MS = 5_000; | |
| 209 | 238 | ||
| ⋯ | |||
| 242 | 271 | export default function Home({ loaderData }: Route.ComponentProps) { | |
| 243 | 272 | const { slug, now, tz, span, digest, happened, attention: waiting, away, running: runningNow, head, spend } = loaderData; | |
| 244 | 273 | useMarkSeen(slug, now); | |
| 274 | + | useHomeLive(slug, useWatched(loaderData.watch)); | |
| 245 | 275 | return ( | |
| 246 | 276 | <div className="mx-auto w-full max-w-260 space-y-5 px-4 py-5 md:px-10 md:py-8"> | |
| 247 | 277 | <OnboardingOnPage slug={slug} view={loaderData.onboarding} asked={loaderData.setupAsked} /> | |
| ⋯ | |||
| 325 | 355 | } | |
| 326 | 356 | > | |
| 327 | 357 | <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} />} | |
| 329 | 359 | </Await> | |
| 330 | 360 | </Suspense> | |
| 331 | 361 | <Suspense fallback={null}> | |
| 332 | 362 | <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} />} | |
| 334 | 364 | </Await> | |
| 335 | 365 | </Suspense> | |
| 336 | 366 | </div> | |
Comments
Sign in to comment.