Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

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
syntaqxcommitted Parent1585bcdBrowse files
7 files+147−130/7 viewed
+21−2
1919 *
2020 * So do repository pages: a tab on one watches its repository, and what
2121 * 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`.
2325 */
2426 import { useEffect, useRef, useState, useSyncExternalStore } from "react";
2527
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";
2729
2830 import type { DesktopRunnerBridge } from "./desktop-runner";
2931 import type { DesktopSettingsBridge } from "./desktop-settings";
464466 return () => repoListeners.delete(listener);
465467 }
466468
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+
467477 function onEvent(event: FeedEvent): void {
468478 switch (event.type) {
469479 case "repo":
475485 }
476486 }
477487 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;
478497 case "hello":
479498 set({ preferences: event.preferences, vapidKey: event.vapid_public_key, recent: addRecent(state.recent, event.notifications ?? []) });
480499 break;
+10−0
1717 newCommits,
1818 repoPages,
1919 tagOf,
20+ workPages,
2021 } from "./repo-live.ts";
2122
2223 const AT = "2026-10-11T10:00:00Z";
263264 assert.equal(atBottom({ top: 880, height: 2000, viewport: 1000 }), true);
264265 assert.equal(atBottom({ top: 500, height: 2000, viewport: 1000 }), false);
265266 });
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−2
1717 * is down, it refreshes when the tab comes back into view and once a
1818 * minute while it is in view instead.
1919 */
20−import type { RepoChange } from "@g1t/contracts";
20+import type { RepoChange, WorkspaceChange } from "@g1t/contracts";
2121
22−export type { RepoChange };
22+export type { RepoChange, WorkspaceChange };
2323
2424 /** Which changes a page shows. */
2525 export type RepoMatch = (change: RepoChange) => boolean;
2626
27+/** Which changes to a workspace's work items a Work page shows. */
28+export type WorkspaceMatch = (change: WorkspaceChange) => boolean;
29+
2730 /** How long after the last matching change the page refreshes. */
2831 export const LIVE_DEBOUNCE_MS = 1000;
2932 /** However steady the changes, a page waits no longer than this before refreshing. */
190193 },
191194 };
192195
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+
193208 /** Commits a set of changes brought to a branch, for "N new commits": null when uncounted. */
194209 export function newCommits(changes: RepoChange[], branch: string | null): number | null {
195210 let total = 0;
+80−7
1616 * `useLive` is the same for a page over several repositories (Home,
1717 * the workspace's): one `watch_repos` asks for them all.
1818 *
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+ *
1923 * Browser-only work happens in effects; on the server the hook does
2024 * nothing.
2125 */
2226 import { useCallback, useEffect, useRef, useState } from "react";
2327 import { useLocation, useNavigation, useRevalidator, useRouteLoaderData } from "react-router";
2428
25−import { onRepoChanges, useFeedConnected } from "./notify-client";
29+import { onRepoChanges, onWorkspaceChanges, useFeedConnected } from "./notify-client";
2630 import {
2731 FALLBACK_FOCUS_MS,
2832 LIVE_DEBOUNCE_MS,
3034 type GateState,
3135 type RepoChange,
3236 type RepoMatch,
37+ type WorkspaceMatch,
3338 atBottom,
3439 fallbackDue,
3540 gate,
7378 signedIn: boolean;
7479 /** However many changes arrive, the page refreshes at most this often (LIVE_MIN_GAP_MS). */
7580 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;
7695 };
7796
7897 export type Live = RepoLive & {
113132 }
114133 }
115134
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+
116159 // ── The page ─────────────────────────────────────────────────────────────
117160
118161 /** Someone typing on the page, or a dialog (or a popover) open over it. */
141184 }
142185
143186 /**
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+/**
144206 * A page that refreshes itself as any of `repos` changes (Home watches
145207 * the workspace's): `useRepoLive` for a page with no repository layout.
146208 */
151213 // One string for the effects: the same repositories are the same watch.
152214 const paths = repos.map((repo) => repo.path).join(" ");
153215 const ids = repos.map((repo) => repo.id).join(" ");
216+ const slug = options.workspace?.slug ?? "";
154217
155218 const connected = useFeedConnected();
156219 const [watching, setWatching] = useState(false);
198261 [check],
199262 );
200263
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.
203266 useEffect(() => {
204− if (!enabled || !signedIn || !connected || !paths) {
267+ if (!enabled || !signedIn || !connected || (!paths && !slug)) {
205268 setWatching(false);
206269 return;
207270 }
208271 let cancelled = false;
209272 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);
212275 });
213276 ask(true);
214277 const renew = setInterval(() => ask(true), REWATCH_MS);
216279 cancelled = true;
217280 clearInterval(renew);
218281 };
219− }, [enabled, signedIn, connected, paths]);
282+ }, [enabled, signedIn, connected, paths, slug]);
220283
221284 // Changes as they arrive.
222285 useEffect(() => {
233296 });
234297 }, [enabled, ids, queue]);
235298
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+
236309 // Another branch or page: what was held is no longer about it.
237310 useEffect(() => {
238311 setHeld([]);
+12−2
77 import type { Route } from "./+types/api";
88 import { repoFor } from "../../lib/access.server";
99 import { identity } from "../../lib/services.server";
10−import { assertSameOrigin, requireUser } from "../../lib/session.server";
10+import { assertSameOrigin, requireUser, roleIn } from "../../lib/session.server";
1111
1212 function client() {
1313 if (!env.NOTIFY) throw data({ error: "Notifications are not set up here." }, { status: 503 });
2525 * updates live: its tabs then hear what changes there (`repo`, its
2626 * `owner/name`), once the person is found to be able to read it
2727 * (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.
2931 *
3032 * The phone app calls this with its access token and no `Origin`, which
3133 * the same-origin check lets through (lib/same-origin.ts).
4749 change?: PresenceChange | SoundSettingsChange;
4850 repo?: string;
4951 repos?: unknown;
52+ workspace?: string;
5053 };
5154
5255 /** The most repositories one `watch_repos` asks for (notify keeps 50 per person). */
115118 const watched = (await Promise.all(paths.map((path) => watchOne(context, notify, user, path).catch(() => null)))).filter((one) => one?.ok);
116119 return Response.json({ ok: watched.length > 0, repo_ids: watched.map((one) => one!.repo_id) });
117120 }
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+ }
118128 default:
119129 return Response.json({ ok: false }, { status: 400 });
120130 }
+4−0
4747 import { itemAbilities } from "../../../lib/work-access";
4848 import { LINK_ROLES, ROLE_LABELS, itemPath, originOf, repoFilter, shortPath, workPath } from "../../../lib/work-items";
4949 import { workspaceProjects } from "../../../lib/workspace-projects.server";
50+import { workPages } from "../../../lib/repo-live";
51+import { useWorkspaceLive } from "../../../lib/use-repo-live";
5052
5153 export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
5254 const item = loaderData?.item;
479481 // Its Work, when it could be read; a failure says so in its places.
480482 const coordination = isFailed(loaderData.coordination) ? null : loaderData.coordination;
481483 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 });
482486 // Follow pull requests at work without a manual reload.
483487 useRefreshWhile(pulls.some((pull) => pull.status === "draft" || pull.checkStatus === "queued" || pull.checkStatus === "running"));
484488 // Claims lapse and sessions go idle with no event to say so: looked at again now and then.
+3−0
1414 import { getViewer, roleIn, unwrap } from "../../../lib/session.server";
1515 import { filtered, newItemPath, repoFilter, shortPath, workFilters, workPath, workQuery } from "../../../lib/work-items";
1616 import { workspaceProjects } from "../../../lib/workspace-projects.server";
17+import { workPages } from "../../../lib/repo-live";
18+import { useWorkspaceLive } from "../../../lib/use-repo-live";
1719
1820 export function meta({ params, location, ...args }: Route.MetaArgs) {
1921 const repo = new URLSearchParams(location.search).get("repo");
6668 const repoName = filters.repo ? shortPath(filters.repo, workspace) : null;
6769 // The tabs keep the filters; each filter keeps the state and the others.
6870 const kept = new URLSearchParams(workQuery({ ...filters, state: "open" }));
71+ useWorkspaceLive({ workspace, member, match: workPages.list() });
6972 return (
7073 <div>
7174 {loaderData.labelsFailed && (

Comments

Sign in to comment.