Web: Work pages update live over the workspace channel
The Work list and a work item's page watch their workspace through /-/notify, members only, and refresh when a change names them.
- G1T-Session:
- ses_01m560bz6eeqc8v1hrrha03pap
7 files+147−130/7 viewed
| 19 | 19 | * | |
| 20 | 20 | * So do repository pages: a tab on one watches its repository, and what | |
| 21 | 21 | * changes there arrives as `repo` events, handed to the page's | |
| 22 | − | * `useRepoLive` (lib/use-repo-live.ts) through `onRepoChanges`. | |
| 22 | + | * `useRepoLive` (lib/use-repo-live.ts) through `onRepoChanges`. A | |
| 23 | + | * workspace's Work pages the same way: what changes to its work items | |
| 24 | + | * arrives as `workspace` events, through `onWorkspaceChanges`. | |
| 23 | 25 | */ | |
| 24 | 26 | import { useEffect, useRef, useState, useSyncExternalStore } from "react"; | |
| 25 | 27 | ||
| 26 | − | import type { FeedCounts, FeedEvent, FeedNotification, NotifyPreferences, OwnPresence, PresenceChange, PresenceEntry, RepoChange } from "@g1t/contracts"; | |
| 28 | + | import type { FeedCounts, FeedEvent, FeedNotification, NotifyPreferences, OwnPresence, PresenceChange, PresenceEntry, RepoChange, WorkspaceChange } from "@g1t/contracts"; | |
| 27 | 29 | ||
| 28 | 30 | import type { DesktopRunnerBridge } from "./desktop-runner"; | |
| 29 | 31 | import type { DesktopSettingsBridge } from "./desktop-settings"; | |
| ⋯ | |||
| 464 | 466 | return () => repoListeners.delete(listener); | |
| 465 | 467 | } | |
| 466 | 468 | ||
| 469 | + | const workspaceListeners = new Set<(changes: WorkspaceChange[]) => void>(); | |
| 470 | + | ||
| 471 | + | /** Hears what changes to the work items of the workspaces this tab watches (lib/use-repo-live.ts). Returns how to stop. */ | |
| 472 | + | export function onWorkspaceChanges(listener: (changes: WorkspaceChange[]) => void): () => void { | |
| 473 | + | workspaceListeners.add(listener); | |
| 474 | + | return () => workspaceListeners.delete(listener); | |
| 475 | + | } | |
| 476 | + | ||
| 467 | 477 | function onEvent(event: FeedEvent): void { | |
| 468 | 478 | switch (event.type) { | |
| 469 | 479 | case "repo": | |
| ⋯ | |||
| 475 | 485 | } | |
| 476 | 486 | } | |
| 477 | 487 | break; | |
| 488 | + | case "workspace": | |
| 489 | + | for (const listener of workspaceListeners) { | |
| 490 | + | try { | |
| 491 | + | listener(Array.isArray(event.changes) ? event.changes : []); | |
| 492 | + | } catch (error) { | |
| 493 | + | console.error("notify: a live Work page failed on a change", error); | |
| 494 | + | } | |
| 495 | + | } | |
| 496 | + | break; | |
| 478 | 497 | case "hello": | |
| 479 | 498 | set({ preferences: event.preferences, vapidKey: event.vapid_public_key, recent: addRecent(state.recent, event.notifications ?? []) }); | |
| 480 | 499 | break; | |
| 17 | 17 | newCommits, | |
| 18 | 18 | repoPages, | |
| 19 | 19 | tagOf, | |
| 20 | + | workPages, | |
| 20 | 21 | } from "./repo-live.ts"; | |
| 21 | 22 | ||
| 22 | 23 | const AT = "2026-10-11T10:00:00Z"; | |
| ⋯ | |||
| 263 | 264 | assert.equal(atBottom({ top: 880, height: 2000, viewport: 1000 }), true); | |
| 264 | 265 | assert.equal(atBottom({ top: 500, height: 2000, viewport: 1000 }), false); | |
| 265 | 266 | }); | |
| 267 | + | ||
| 268 | + | test("a workspace's Work pages: the list hears every work item, an item page its own", () => { | |
| 269 | + | const change = (number: number | null) => ({ workspace: "acme", kind: "work_item.updated", number, at: "2026-10-17T00:00:00Z" }); | |
| 270 | + | assert.equal(workPages.list()(change(7)), true); | |
| 271 | + | assert.equal(workPages.list()(change(null)), true); | |
| 272 | + | assert.equal(workPages.item(7)(change(7)), true); | |
| 273 | + | assert.equal(workPages.item(7)(change(8)), false); | |
| 274 | + | assert.equal(workPages.item(7)(change(null)), false); | |
| 275 | + | }); | |
| 17 | 17 | * is down, it refreshes when the tab comes back into view and once a | |
| 18 | 18 | * minute while it is in view instead. | |
| 19 | 19 | */ | |
| 20 | − | import type { RepoChange } from "@g1t/contracts"; | |
| 20 | + | import type { RepoChange, WorkspaceChange } from "@g1t/contracts"; | |
| 21 | 21 | ||
| 22 | − | export type { RepoChange }; | |
| 22 | + | export type { RepoChange, WorkspaceChange }; | |
| 23 | 23 | ||
| 24 | 24 | /** Which changes a page shows. */ | |
| 25 | 25 | export type RepoMatch = (change: RepoChange) => boolean; | |
| 26 | 26 | ||
| 27 | + | /** Which changes to a workspace's work items a Work page shows. */ | |
| 28 | + | export type WorkspaceMatch = (change: WorkspaceChange) => boolean; | |
| 29 | + | ||
| 27 | 30 | /** How long after the last matching change the page refreshes. */ | |
| 28 | 31 | export const LIVE_DEBOUNCE_MS = 1000; | |
| 29 | 32 | /** However steady the changes, a page waits no longer than this before refreshing. */ | |
| ⋯ | |||
| 190 | 193 | }, | |
| 191 | 194 | }; | |
| 192 | 195 | ||
| 196 | + | /** What each of a workspace's Work pages shows (`useWorkspaceLive`). */ | |
| 197 | + | export const workPages = { | |
| 198 | + | /** The Work list: any change to any of the workspace's work items. */ | |
| 199 | + | list(): WorkspaceMatch { | |
| 200 | + | return () => true; | |
| 201 | + | }, | |
| 202 | + | /** A work item: its own changes. */ | |
| 203 | + | item(number: number): WorkspaceMatch { | |
| 204 | + | return (change) => change.number === number; | |
| 205 | + | }, | |
| 206 | + | }; | |
| 207 | + | ||
| 193 | 208 | /** Commits a set of changes brought to a branch, for "N new commits": null when uncounted. */ | |
| 194 | 209 | export function newCommits(changes: RepoChange[], branch: string | null): number | null { | |
| 195 | 210 | let total = 0; | |
| 16 | 16 | * `useLive` is the same for a page over several repositories (Home, | |
| 17 | 17 | * the workspace's): one `watch_repos` asks for them all. | |
| 18 | 18 | * | |
| 19 | + | * `useWorkspaceLive` is the same for a workspace's Work pages: a member's | |
| 20 | + | * tab watches the workspace (`watch_workspace`), and what changes to its | |
| 21 | + | * work items arrives the same way, whichever repositories they link. | |
| 22 | + | * | |
| 19 | 23 | * Browser-only work happens in effects; on the server the hook does | |
| 20 | 24 | * nothing. | |
| 21 | 25 | */ | |
| 22 | 26 | import { useCallback, useEffect, useRef, useState } from "react"; | |
| 23 | 27 | import { useLocation, useNavigation, useRevalidator, useRouteLoaderData } from "react-router"; | |
| 24 | 28 | ||
| 25 | − | import { onRepoChanges, useFeedConnected } from "./notify-client"; | |
| 29 | + | import { onRepoChanges, onWorkspaceChanges, useFeedConnected } from "./notify-client"; | |
| 26 | 30 | import { | |
| 27 | 31 | FALLBACK_FOCUS_MS, | |
| 28 | 32 | LIVE_DEBOUNCE_MS, | |
| ⋯ | |||
| 30 | 34 | type GateState, | |
| 31 | 35 | type RepoChange, | |
| 32 | 36 | type RepoMatch, | |
| 37 | + | type WorkspaceMatch, | |
| 33 | 38 | atBottom, | |
| 34 | 39 | fallbackDue, | |
| 35 | 40 | gate, | |
| ⋯ | |||
| 73 | 78 | signedIn: boolean; | |
| 74 | 79 | /** However many changes arrive, the page refreshes at most this often (LIVE_MIN_GAP_MS). */ | |
| 75 | 80 | gap?: number; | |
| 81 | + | /** A workspace whose work items the page hears of too (its slug), and which of those changes refresh it. */ | |
| 82 | + | workspace?: { slug: string; match: WorkspaceMatch } | null; | |
| 83 | + | }; | |
| 84 | + | ||
| 85 | + | export type WorkspaceLiveOptions = { | |
| 86 | + | /** The workspace's slug. */ | |
| 87 | + | workspace: string; | |
| 88 | + | /** Which changes to its work items refresh the page. */ | |
| 89 | + | match: WorkspaceMatch; | |
| 90 | + | /** Whether the viewer is a member: only a member's tab hears the workspace. */ | |
| 91 | + | member: boolean; | |
| 92 | + | /** A reader at the bottom of the page stays there as new items arrive (a work item's timeline). */ | |
| 93 | + | followBottom?: boolean; | |
| 94 | + | enabled?: boolean; | |
| 76 | 95 | }; | |
| 77 | 96 | ||
| 78 | 97 | export type Live = RepoLive & { | |
| ⋯ | |||
| 113 | 132 | } | |
| 114 | 133 | } | |
| 115 | 134 | ||
| 135 | + | /** Asks to hear what changes to a workspace's work items; true when it is watched. */ | |
| 136 | + | async function watchWorkspace(slug: string, force: boolean): Promise<boolean> { | |
| 137 | + | const key = `workspace:${slug}`; | |
| 138 | + | const now = Date.now(); | |
| 139 | + | const last = asked.get(key); | |
| 140 | + | if (!force && last != null && now - last < ASK_AGAIN_MS) return true; | |
| 141 | + | asked.set(key, now); | |
| 142 | + | try { | |
| 143 | + | const answer = await fetch("/-/notify", { | |
| 144 | + | method: "POST", | |
| 145 | + | headers: { "content-type": "application/json", accept: "application/json" }, | |
| 146 | + | credentials: "same-origin", | |
| 147 | + | body: JSON.stringify({ intent: "watch_workspace", workspace: slug }), | |
| 148 | + | }); | |
| 149 | + | if (!answer.ok) throw new Error(`status ${answer.status}`); | |
| 150 | + | const body = (await answer.json().catch(() => null)) as { ok?: unknown } | null; | |
| 151 | + | if (body?.ok !== true) throw new Error("refused"); | |
| 152 | + | return true; | |
| 153 | + | } catch { | |
| 154 | + | asked.delete(key); | |
| 155 | + | return false; | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | ||
| 116 | 159 | // ── The page ───────────────────────────────────────────────────────────── | |
| 117 | 160 | ||
| 118 | 161 | /** Someone typing on the page, or a dialog (or a popover) open over it. */ | |
| ⋯ | |||
| 141 | 184 | } | |
| 142 | 185 | ||
| 143 | 186 | /** | |
| 187 | + | * A workspace's Work page that refreshes itself as its work items change: | |
| 188 | + | * `useLive` over the workspace instead of repositories. | |
| 189 | + | */ | |
| 190 | + | export function useWorkspaceLive(options: WorkspaceLiveOptions): RepoLive { | |
| 191 | + | const { workspace, match, member, followBottom } = options; | |
| 192 | + | const { live, held, show } = useLive({ | |
| 193 | + | match: () => false, | |
| 194 | + | repos: NO_REPOS, | |
| 195 | + | workspace: { slug: workspace.toLowerCase(), match }, | |
| 196 | + | signedIn: member, | |
| 197 | + | followBottom, | |
| 198 | + | enabled: options.enabled, | |
| 199 | + | }); | |
| 200 | + | return { live, held, show }; | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | const NO_REPOS: readonly WatchedRepo[] = []; | |
| 204 | + | ||
| 205 | + | /** | |
| 144 | 206 | * A page that refreshes itself as any of `repos` changes (Home watches | |
| 145 | 207 | * the workspace's): `useRepoLive` for a page with no repository layout. | |
| 146 | 208 | */ | |
| ⋯ | |||
| 151 | 213 | // One string for the effects: the same repositories are the same watch. | |
| 152 | 214 | const paths = repos.map((repo) => repo.path).join(" "); | |
| 153 | 215 | const ids = repos.map((repo) => repo.id).join(" "); | |
| 216 | + | const slug = options.workspace?.slug ?? ""; | |
| 154 | 217 | ||
| 155 | 218 | const connected = useFeedConnected(); | |
| 156 | 219 | const [watching, setWatching] = useState(false); | |
| ⋯ | |||
| 198 | 261 | [check], | |
| 199 | 262 | ); | |
| 200 | 263 | ||
| 201 | − | // Watching the repositories: on opening the page, whenever the socket | |
| 202 | − | // (re)connects, and before the watch runs out. | |
| 264 | + | // Watching the repositories (and the workspace): on opening the page, | |
| 265 | + | // whenever the socket (re)connects, and before the watch runs out. | |
| 203 | 266 | useEffect(() => { | |
| 204 | − | if (!enabled || !signedIn || !connected || !paths) { | |
| 267 | + | if (!enabled || !signedIn || !connected || (!paths && !slug)) { | |
| 205 | 268 | setWatching(false); | |
| 206 | 269 | return; | |
| 207 | 270 | } | |
| 208 | 271 | let cancelled = false; | |
| 209 | 272 | const ask = (force: boolean) => | |
| 210 | − | void watchRepos(paths.split(" "), force).then((ok) => { | |
| 211 | − | if (!cancelled) setWatching(ok); | |
| 273 | + | void Promise.all([paths ? watchRepos(paths.split(" "), force) : false, slug ? watchWorkspace(slug, force) : false]).then(([repos, workspace]) => { | |
| 274 | + | if (!cancelled) setWatching(repos || workspace); | |
| 212 | 275 | }); | |
| 213 | 276 | ask(true); | |
| 214 | 277 | const renew = setInterval(() => ask(true), REWATCH_MS); | |
| ⋯ | |||
| 216 | 279 | cancelled = true; | |
| 217 | 280 | clearInterval(renew); | |
| 218 | 281 | }; | |
| 219 | − | }, [enabled, signedIn, connected, paths]); | |
| 282 | + | }, [enabled, signedIn, connected, paths, slug]); | |
| 220 | 283 | ||
| 221 | 284 | // Changes as they arrive. | |
| 222 | 285 | useEffect(() => { | |
| ⋯ | |||
| 233 | 296 | }); | |
| 234 | 297 | }, [enabled, ids, queue]); | |
| 235 | 298 | ||
| 299 | + | // What changes to the workspace's work items, as it arrives. | |
| 300 | + | useEffect(() => { | |
| 301 | + | if (!enabled || !slug) return; | |
| 302 | + | return onWorkspaceChanges((changes) => { | |
| 303 | + | const match = latest.current.options.workspace?.match; | |
| 304 | + | const mine = changes.filter((change) => change.workspace?.toLowerCase() === slug && !!match?.(change)); | |
| 305 | + | if (mine.length && heldCount.current === 0) queue(); | |
| 306 | + | }); | |
| 307 | + | }, [enabled, slug, queue]); | |
| 308 | + | ||
| 236 | 309 | // Another branch or page: what was held is no longer about it. | |
| 237 | 310 | useEffect(() => { | |
| 238 | 311 | setHeld([]); | |
| 7 | 7 | import type { Route } from "./+types/api"; | |
| 8 | 8 | import { repoFor } from "../../lib/access.server"; | |
| 9 | 9 | import { identity } from "../../lib/services.server"; | |
| 10 | − | import { assertSameOrigin, requireUser } from "../../lib/session.server"; | |
| 10 | + | import { assertSameOrigin, requireUser, roleIn } from "../../lib/session.server"; | |
| 11 | 11 | ||
| 12 | 12 | function client() { | |
| 13 | 13 | if (!env.NOTIFY) throw data({ error: "Notifications are not set up here." }, { status: 503 }); | |
| ⋯ | |||
| 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 | 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 | + | * at most WATCH_REPOS_MAX: Home watches its workspace's); `watch_workspace` | |
| 29 | + | * for a workspace's Work pages (`workspace`, its slug): its members' tabs | |
| 30 | + | * then hear what changes to its work items. | |
| 29 | 31 | * | |
| 30 | 32 | * The phone app calls this with its access token and no `Origin`, which | |
| 31 | 33 | * the same-origin check lets through (lib/same-origin.ts). | |
| ⋯ | |||
| 47 | 49 | change?: PresenceChange | SoundSettingsChange; | |
| 48 | 50 | repo?: string; | |
| 49 | 51 | repos?: unknown; | |
| 52 | + | workspace?: string; | |
| 50 | 53 | }; | |
| 51 | 54 | ||
| 52 | 55 | /** The most repositories one `watch_repos` asks for (notify keeps 50 per person). */ | |
| ⋯ | |||
| 115 | 118 | const watched = (await Promise.all(paths.map((path) => watchOne(context, notify, user, path).catch(() => null)))).filter((one) => one?.ok); | |
| 116 | 119 | return Response.json({ ok: watched.length > 0, repo_ids: watched.map((one) => one!.repo_id) }); | |
| 117 | 120 | } | |
| 121 | + | case "watch_workspace": { | |
| 122 | + | const slug = String(sent.workspace ?? "").toLowerCase(); | |
| 123 | + | if (!slug) return Response.json({ ok: false }, { status: 400 }); | |
| 124 | + | // Only a workspace they belong to: what changes to its work items is its members'. | |
| 125 | + | if (roleIn(user, slug) == null) return Response.json({ ok: false }, { status: 404 }); | |
| 126 | + | return Response.json(await notify.watchWorkspace(user, slug)); | |
| 127 | + | } | |
| 118 | 128 | default: | |
| 119 | 129 | return Response.json({ ok: false }, { status: 400 }); | |
| 120 | 130 | } | |
| 47 | 47 | import { itemAbilities } from "../../../lib/work-access"; | |
| 48 | 48 | import { LINK_ROLES, ROLE_LABELS, itemPath, originOf, repoFilter, shortPath, workPath } from "../../../lib/work-items"; | |
| 49 | 49 | import { workspaceProjects } from "../../../lib/workspace-projects.server"; | |
| 50 | + | import { workPages } from "../../../lib/repo-live"; | |
| 51 | + | import { useWorkspaceLive } from "../../../lib/use-repo-live"; | |
| 50 | 52 | ||
| 51 | 53 | export function meta({ loaderData, params, ...args }: Route.MetaArgs) { | |
| 52 | 54 | const item = loaderData?.item; | |
| ⋯ | |||
| 479 | 481 | // Its Work, when it could be read; a failure says so in its places. | |
| 480 | 482 | const coordination = isFailed(loaderData.coordination) ? null : loaderData.coordination; | |
| 481 | 483 | const sessionTrees = isFailed(loaderData.sessionTrees) ? null : loaderData.sessionTrees; | |
| 484 | + | // What changes to it (comments, labels, links, its pull requests) shows as it happens. | |
| 485 | + | useWorkspaceLive({ workspace, member: loaderData.member, match: workPages.item(item.number), followBottom: true }); | |
| 482 | 486 | // Follow pull requests at work without a manual reload. | |
| 483 | 487 | useRefreshWhile(pulls.some((pull) => pull.status === "draft" || pull.checkStatus === "queued" || pull.checkStatus === "running")); | |
| 484 | 488 | // Claims lapse and sessions go idle with no event to say so: looked at again now and then. | |
| 14 | 14 | import { getViewer, roleIn, unwrap } from "../../../lib/session.server"; | |
| 15 | 15 | import { filtered, newItemPath, repoFilter, shortPath, workFilters, workPath, workQuery } from "../../../lib/work-items"; | |
| 16 | 16 | import { workspaceProjects } from "../../../lib/workspace-projects.server"; | |
| 17 | + | import { workPages } from "../../../lib/repo-live"; | |
| 18 | + | import { useWorkspaceLive } from "../../../lib/use-repo-live"; | |
| 17 | 19 | ||
| 18 | 20 | export function meta({ params, location, ...args }: Route.MetaArgs) { | |
| 19 | 21 | const repo = new URLSearchParams(location.search).get("repo"); | |
| ⋯ | |||
| 66 | 68 | const repoName = filters.repo ? shortPath(filters.repo, workspace) : null; | |
| 67 | 69 | // The tabs keep the filters; each filter keeps the state and the others. | |
| 68 | 70 | const kept = new URLSearchParams(workQuery({ ...filters, state: "open" })); | |
| 71 | + | useWorkspaceLive({ workspace, member, match: workPages.list() }); | |
| 69 | 72 | return ( | |
| 70 | 73 | <div> | |
| 71 | 74 | {loaderData.labelsFailed && ( | |
Comments
Sign in to comment.