diff --git a/desktop/playwright.b3-pr-capture.config.ts b/desktop/playwright.b3-pr-capture.config.ts
new file mode 100644
index 0000000000..8dcc374d69
--- /dev/null
+++ b/desktop/playwright.b3-pr-capture.config.ts
@@ -0,0 +1,34 @@
+import { defineConfig, devices } from "@playwright/test";
+
+export default defineConfig({
+ testDir: "./tests/e2e",
+ outputDir: "test-results-b3-pr-demo",
+ timeout: 120_000,
+ retries: 0,
+ workers: 1,
+ reporter: [["list"]],
+ use: {
+ baseURL: "http://127.0.0.1:4173",
+ ...devices["Desktop Chrome"],
+ viewport: { width: 1440, height: 900 },
+ video: { mode: "on", size: { width: 1440, height: 900 } },
+ screenshot: "on",
+ trace: "off",
+ },
+ projects: [
+ {
+ name: "relay-b3-pr-capture",
+ testMatch: "**/workstream-board-b3-pr.capture.spec.ts",
+ },
+ {
+ name: "canvas-bridge-regression",
+ testMatch: "**/get-canvas-bridge.spec.ts",
+ },
+ ],
+ webServer: {
+ command: "python3 -m http.server 4173 -d dist",
+ cwd: ".",
+ reuseExistingServer: false,
+ url: "http://127.0.0.1:4173",
+ },
+});
diff --git a/desktop/src/features/workstream-board/lib/workstreamPullRequestStatus.test.mjs b/desktop/src/features/workstream-board/lib/workstreamPullRequestStatus.test.mjs
new file mode 100644
index 0000000000..ec8c6e2f6f
--- /dev/null
+++ b/desktop/src/features/workstream-board/lib/workstreamPullRequestStatus.test.mjs
@@ -0,0 +1,271 @@
+import assert from "node:assert/strict";
+import { after, before, test } from "node:test";
+import { JSDOM } from "jsdom";
+
+const dom = new JSDOM("
", {
+ url: "http://localhost",
+});
+
+before(() => {
+ Object.assign(globalThis, {
+ document: dom.window.document,
+ HTMLElement: dom.window.HTMLElement,
+ IS_REACT_ACT_ENVIRONMENT: true,
+ window: dom.window,
+ });
+});
+
+after(() => dom.window.close());
+
+import {
+ createWorkstreamPollingRegistry,
+ fetchBuzzPullRequestStatus,
+ fetchGithubPullRequestStatus,
+ parseWorkstreamPullRequestReference,
+ parseWorkstreamPullRequestReferences,
+} from "./workstreamPullRequestStatus.ts";
+
+const OWNER = "a".repeat(64);
+const EVENT = "b".repeat(64);
+const buzz = `buzz://pr?id=${EVENT}&owner=${OWNER}&d=buzz`;
+
+function response(body, status = 200) {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "content-type": "application/json" },
+ });
+}
+
+test("parses canonical Buzz, GitHub, malformed, and unsupported references", () => {
+ const refs = parseWorkstreamPullRequestReferences([
+ buzz,
+ { url: "https://github.com/Block/Buzz/pull/42", label: "labeled PR" },
+ "https://github.com/Block/Buzz/pull/42",
+ "https://github.com/block/buzz/issues/42",
+ "https://example.com/pr/1",
+ "not a url",
+ null,
+ ]);
+ assert.equal(refs[0].kind, "buzz");
+ assert.equal(refs[0].identity, `buzz:${OWNER}:buzz:${EVENT}`);
+ assert.equal(refs[1].kind, "github");
+ assert.equal(refs[1].href, "https://github.com/block/buzz/pull/42");
+ assert.equal(refs[2].kind, "github");
+ assert.equal(refs[3].kind, "unsupported");
+ assert.equal(refs[3].reason, "malformed");
+ assert.equal(refs[4].reason, "unsupported-provider");
+ assert.equal(refs[5].reason, "malformed");
+ assert.equal(refs[6].reason, "invalid-field");
+});
+
+test("GitHub status reports lifecycle and latest review state", async () => {
+ const ref = parseWorkstreamPullRequestReference(
+ "https://github.com/block/buzz/pull/42",
+ );
+ assert.equal(ref.kind, "github");
+ const result = await fetchGithubPullRequestStatus(ref, {
+ fetcher: async (url) =>
+ String(url).endsWith("/reviews?per_page=100")
+ ? response([
+ {
+ user: { login: "reviewer" },
+ state: "APPROVED",
+ submitted_at: "2026-08-18T00:00:00Z",
+ },
+ ])
+ : response({
+ number: 42,
+ title: "Board PR",
+ state: "closed",
+ draft: false,
+ merged_at: "2026-08-18T01:00:00Z",
+ requested_reviewers: [],
+ }),
+ });
+ assert.deepEqual(result, {
+ status: "live",
+ provider: "github",
+ href: "https://github.com/block/buzz/pull/42",
+ repository: "block/buzz",
+ number: 42,
+ title: "Board PR",
+ lifecycle: "merged",
+ reviewState: "approved",
+ });
+});
+
+test("Buzz status consumes root and existing NIP-34 status events", async () => {
+ const ref = parseWorkstreamPullRequestReference(buzz);
+ assert.equal(ref.kind, "buzz");
+ const root = {
+ id: EVENT,
+ pubkey: OWNER,
+ kind: 1618,
+ created_at: 10,
+ content: "Wire board",
+ tags: [
+ ["a", `30617:${OWNER}:buzz`],
+ ["d", "pr-1"],
+ ["subject", "Wire board"],
+ ["p", "c".repeat(64)],
+ ],
+ };
+ const status = {
+ id: "c".repeat(64),
+ pubkey: OWNER,
+ kind: 1631,
+ created_at: 20,
+ content: "",
+ tags: [
+ ["e", EVENT],
+ ["a", `30617:${OWNER}:buzz`],
+ ],
+ };
+ const result = await fetchBuzzPullRequestStatus(ref, async (filter) =>
+ filter.kinds[0] === 1618 ? [root] : [status],
+ );
+ assert.equal(result.status, "live");
+ assert.equal(result.lifecycle, "merged");
+ assert.equal(result.title, "Wire board");
+ assert.equal(result.reviewState, "review-requested");
+});
+
+test("polling deduplicates canonical identities and tears down", async () => {
+ let requests = 0;
+ let aborted = false;
+ let resolveRequest;
+ const pending = new Promise((resolve) => {
+ resolveRequest = resolve;
+ });
+ const reference = parseWorkstreamPullRequestReference(
+ "https://github.com/block/buzz/pull/42",
+ );
+ assert.equal(reference.kind, "github");
+ const registry = createWorkstreamPollingRegistry({
+ fetcher: async (_url, init) => {
+ requests += 1;
+ init.signal.addEventListener("abort", () => {
+ aborted = true;
+ });
+ await pending;
+ return response({
+ number: 42,
+ title: "Board",
+ state: "open",
+ draft: false,
+ });
+ },
+ intervalMs: 1000,
+ });
+ const listener = () => {};
+ const unsubscribeOne = registry.subscribe(reference, listener);
+ const unsubscribeTwo = registry.subscribe(reference, () => {});
+ assert.equal(registry.entryCount(), 1);
+ await Promise.resolve();
+ await Promise.resolve();
+ assert.equal(requests, 2);
+ unsubscribeOne();
+ assert.equal(registry.entryCount(), 1);
+ unsubscribeTwo();
+ assert.equal(registry.entryCount(), 0);
+ assert.equal(aborted, true);
+ resolveRequest();
+});
+
+test("workstream status hook exposes loading before a supported PR resolves", async () => {
+ const { cleanup, renderHook } = await import("@testing-library/react");
+ const { useWorkstreamPullRequestStatuses } = await import(
+ "./workstreamPullRequestStatus.ts"
+ );
+ const reference = parseWorkstreamPullRequestReference(
+ "https://github.com/block/buzz/pull/42",
+ );
+ const registry = createWorkstreamPollingRegistry({
+ fetcher: () => new Promise(() => {}),
+ });
+ const references = [reference];
+ const { result, unmount } = renderHook(() =>
+ useWorkstreamPullRequestStatuses(references, registry),
+ );
+ assert.deepEqual(result.current.get(reference.identity), {
+ status: "loading",
+ });
+ unmount();
+ cleanup();
+});
+
+test("workstream status hook preserves a synchronous live callback", async () => {
+ const { act, cleanup, renderHook } = await import("@testing-library/react");
+ const { useWorkstreamPullRequestStatuses } = await import(
+ "./workstreamPullRequestStatus.ts"
+ );
+ const reference = parseWorkstreamPullRequestReference(
+ "https://github.com/block/buzz/pull/42",
+ );
+ const registry = createWorkstreamPollingRegistry({
+ fetcher: async (url) =>
+ String(url).endsWith("/reviews?per_page=100")
+ ? response([])
+ : response({ number: 42, title: "Board", state: "open", draft: false }),
+ maxPolls: 1,
+ });
+ const prime = registry.subscribe(reference, () => {});
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ const references = [reference];
+ const { result, unmount } = renderHook(() =>
+ useWorkstreamPullRequestStatuses(references, registry),
+ );
+ await act(async () => {
+ await Promise.resolve();
+ });
+ assert.deepEqual(result.current.get(reference.identity), {
+ status: "live",
+ provider: "github",
+ href: "https://github.com/block/buzz/pull/42",
+ repository: "block/buzz",
+ number: 42,
+ title: "Board",
+ lifecycle: "open",
+ reviewState: "no-reviews",
+ });
+ unmount();
+ prime();
+ cleanup();
+});
+
+test("workstream status hook removes stale references and preserves unsupported state", async () => {
+ const { act, cleanup, renderHook } = await import("@testing-library/react");
+ const { useWorkstreamPullRequestStatuses } = await import(
+ "./workstreamPullRequestStatus.ts"
+ );
+ const supported = parseWorkstreamPullRequestReference(
+ "https://github.com/block/buzz/pull/42",
+ );
+ const unsupported = parseWorkstreamPullRequestReference(
+ "https://example.com/pr/1",
+ );
+ const registry = createWorkstreamPollingRegistry({
+ fetcher: () => new Promise(() => {}),
+ });
+ const { result, rerender, unmount } = renderHook(
+ ({ refs }) => useWorkstreamPullRequestStatuses(refs, registry),
+ { initialProps: { refs: [supported, unsupported] } },
+ );
+ assert.deepEqual(
+ result.current.get(unsupported.identity)?.status,
+ "unsupported",
+ );
+ await act(async () => {
+ rerender({ refs: [unsupported] });
+ });
+ assert.equal(result.current.has(supported.identity), false);
+ assert.deepEqual(
+ result.current.get(unsupported.identity)?.status,
+ "unsupported",
+ );
+ unmount();
+ cleanup();
+});
diff --git a/desktop/src/features/workstream-board/lib/workstreamPullRequestStatus.ts b/desktop/src/features/workstream-board/lib/workstreamPullRequestStatus.ts
new file mode 100644
index 0000000000..ed5e66b7a3
--- /dev/null
+++ b/desktop/src/features/workstream-board/lib/workstreamPullRequestStatus.ts
@@ -0,0 +1,568 @@
+import * as React from "react";
+
+import { eventToProjectPullRequest } from "@/features/projects/projectPullRequests.mjs";
+import { relayClient } from "@/shared/api/relayClient";
+import type { RelayEvent } from "@/shared/api/types";
+import {
+ KIND_GIT_PULL_REQUEST,
+ KIND_GIT_STATUS_CLOSED,
+ KIND_GIT_STATUS_DRAFT,
+ KIND_GIT_STATUS_MERGED,
+ KIND_GIT_STATUS_OPEN,
+} from "@/shared/constants/kinds";
+import { buildPullRequestLink, parseEntityLink } from "@/shared/lib/entityLink";
+
+export type WorkstreamPullRequestReference =
+ | {
+ kind: "buzz";
+ rawUrl: string;
+ href: string;
+ identity: string;
+ repoAddress: string;
+ eventId: string;
+ repository: string;
+ }
+ | {
+ kind: "github";
+ rawUrl: string;
+ href: string;
+ identity: string;
+ owner: string;
+ repository: string;
+ number: number;
+ }
+ | {
+ kind: "unsupported";
+ rawUrl: string;
+ href: string;
+ identity: string;
+ reason: "malformed" | "unsupported-provider" | "invalid-field";
+ };
+
+export type WorkstreamReviewState =
+ | "approved"
+ | "changes-requested"
+ | "review-requested"
+ | "no-reviews";
+
+export type WorkstreamPullRequestStatus = {
+ status: "live";
+ provider: "buzz" | "github";
+ href: string;
+ repository: string;
+ number: number | null;
+ title: string;
+ lifecycle: "draft" | "open" | "merged" | "closed";
+ reviewState: WorkstreamReviewState;
+};
+
+export type WorkstreamPullRequestUnknown = {
+ status: "unknown";
+ href: string;
+ reason: "not-found";
+};
+
+export type WorkstreamPullRequestDisplayState =
+ | { status: "loading" }
+ | { status: "error"; href: string }
+ | {
+ status: "unsupported";
+ href: string;
+ reason: "malformed" | "unsupported-provider" | "invalid-field";
+ }
+ | WorkstreamPullRequestStatus
+ | WorkstreamPullRequestUnknown;
+
+const GITHUB_HOSTS = new Set(["github.com", "www.github.com"]);
+const GITHUB_URL_RE = /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+\/?$/i;
+const MAX_GITHUB_POLLS = 10;
+const DEFAULT_GITHUB_POLL_INTERVAL_MS = 30_000;
+const DEFAULT_GITHUB_POLL_LIFETIME_MS = 5 * 60_000;
+const NIP34_STATUS_KINDS = [
+ KIND_GIT_STATUS_OPEN,
+ KIND_GIT_STATUS_MERGED,
+ KIND_GIT_STATUS_CLOSED,
+ KIND_GIT_STATUS_DRAFT,
+];
+
+function normalizeRawUrl(value: unknown): string {
+ if (typeof value === "string") return value.trim();
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
+ const url = (value as { url?: unknown }).url;
+ if (typeof url === "string") return url.trim();
+ }
+ return "[invalid reference]";
+}
+
+function unsupportedReference(
+ rawUrl: string,
+ reason: Extract<
+ WorkstreamPullRequestReference,
+ { kind: "unsupported" }
+ >["reason"],
+): WorkstreamPullRequestReference {
+ return {
+ kind: "unsupported",
+ rawUrl,
+ href: rawUrl,
+ identity: `unsupported:${reason}:${rawUrl}`,
+ reason,
+ };
+}
+
+/** Parse a single v1 canvas PR reference without throwing. */
+export function parseWorkstreamPullRequestReference(
+ value: unknown,
+): WorkstreamPullRequestReference {
+ const rawUrl = normalizeRawUrl(value);
+ if (!rawUrl || rawUrl === "[invalid reference]") {
+ return unsupportedReference(rawUrl, "invalid-field");
+ }
+
+ if (rawUrl.startsWith("buzz://")) {
+ const parsed = parseEntityLink(rawUrl);
+ if (!parsed.ok || parsed.value.type !== "pr") {
+ return unsupportedReference(rawUrl, "malformed");
+ }
+
+ const { id, owner, dtag } = parsed.value;
+ return {
+ kind: "buzz",
+ rawUrl,
+ href: buildPullRequestLink({ dtag, id, owner }),
+ identity: `buzz:${owner.toLowerCase()}:${dtag}:${id.toLowerCase()}`,
+ repoAddress: `30617:${owner.toLowerCase()}:${dtag}`,
+ eventId: id.toLowerCase(),
+ repository: dtag,
+ };
+ }
+
+ let parsed: URL;
+ try {
+ parsed = new URL(rawUrl);
+ } catch {
+ return unsupportedReference(rawUrl, "malformed");
+ }
+
+ if (!GITHUB_HOSTS.has(parsed.hostname.toLowerCase())) {
+ return unsupportedReference(rawUrl, "unsupported-provider");
+ }
+ if (
+ parsed.username ||
+ parsed.password ||
+ parsed.port ||
+ parsed.protocol !== "https:"
+ ) {
+ return unsupportedReference(rawUrl, "malformed");
+ }
+
+ const segments = parsed.pathname.split("/").filter(Boolean);
+ if (
+ segments.length !== 4 ||
+ segments[2].toLowerCase() !== "pull" ||
+ !/^\d+$/.test(segments[3])
+ ) {
+ return unsupportedReference(rawUrl, "malformed");
+ }
+
+ const number = Number(segments[3]);
+ if (
+ !Number.isSafeInteger(number) ||
+ number < 1 ||
+ !GITHUB_URL_RE.test(rawUrl)
+ ) {
+ return unsupportedReference(rawUrl, "malformed");
+ }
+
+ const owner = segments[0].toLowerCase();
+ const repository = segments[1].toLowerCase();
+ const href = `https://github.com/${owner}/${repository}/pull/${number}`;
+ return {
+ kind: "github",
+ rawUrl,
+ href,
+ identity: `github:${owner}/${repository}#${number}`,
+ owner,
+ repository,
+ number,
+ };
+}
+
+export function parseWorkstreamPullRequestReferences(
+ values: readonly unknown[],
+): WorkstreamPullRequestReference[] {
+ return values.map(parseWorkstreamPullRequestReference);
+}
+
+type GithubResponse = {
+ state?: unknown;
+ draft?: unknown;
+ merged_at?: unknown;
+ number?: unknown;
+ title?: unknown;
+ requested_reviewers?: unknown;
+};
+
+type GithubReview = {
+ user?: { login?: unknown } | null;
+ state?: unknown;
+ submitted_at?: unknown;
+ updated_at?: unknown;
+};
+
+export type GithubFetch = (
+ input: RequestInfo | URL,
+ init?: RequestInit,
+) => Promise;
+
+type EventFetcher = (
+ filter: Parameters[0],
+) => Promise;
+
+function isGithubReview(value: unknown): value is GithubReview {
+ return typeof value === "object" && value !== null;
+}
+
+function reviewState(
+ reviews: unknown[],
+ requestedReviewers: unknown,
+): WorkstreamReviewState {
+ const latestByReviewer = new Map();
+ for (const review of reviews.filter(isGithubReview)) {
+ const login =
+ typeof review.user?.login === "string" ? review.user.login : null;
+ if (!login) continue;
+ const current = latestByReviewer.get(login.toLowerCase());
+ const currentAt = String(
+ current?.submitted_at ?? current?.updated_at ?? "",
+ );
+ const nextAt = String(review.submitted_at ?? review.updated_at ?? "");
+ if (!current || nextAt >= currentAt) {
+ latestByReviewer.set(login.toLowerCase(), review);
+ }
+ }
+
+ const latestStates = [...latestByReviewer.values()].map((review) =>
+ typeof review.state === "string" ? review.state.toUpperCase() : "",
+ );
+ if (latestStates.includes("CHANGES_REQUESTED")) return "changes-requested";
+ if (latestStates.includes("APPROVED")) return "approved";
+ if (Array.isArray(requestedReviewers) && requestedReviewers.length > 0) {
+ return "review-requested";
+ }
+ return "no-reviews";
+}
+
+function lifecycleFromGithubResponse(
+ response: GithubResponse,
+): WorkstreamPullRequestStatus["lifecycle"] {
+ if (typeof response.merged_at === "string" && response.merged_at) {
+ return "merged";
+ }
+ if (response.draft === true) return "draft";
+ return response.state === "closed" ? "closed" : "open";
+}
+
+async function fetchJson(
+ fetcher: GithubFetch,
+ url: string,
+ signal?: AbortSignal,
+): Promise {
+ const response = await fetcher(url, {
+ headers: { Accept: "application/vnd.github+json" },
+ signal,
+ });
+ if (response.status === 404) return null;
+ if (!response.ok) {
+ throw new Error(`GitHub metadata request failed (${response.status}).`);
+ }
+ return response.json();
+}
+
+/** Fetch bounded, unauthenticated GitHub PR metadata for one canonical PR. */
+export async function fetchGithubPullRequestStatus(
+ reference: Extract,
+ options: { fetcher?: GithubFetch; signal?: AbortSignal } = {},
+): Promise {
+ const fetcher = options.fetcher ?? fetch;
+ const base = `https://api.github.com/repos/${reference.owner}/${reference.repository}/pulls/${reference.number}`;
+ const [rawPullRequest, rawReviews] = await Promise.all([
+ fetchJson(fetcher, base, options.signal),
+ fetchJson(fetcher, `${base}/reviews?per_page=100`, options.signal),
+ ]);
+
+ if (rawPullRequest === null) {
+ return { status: "unknown", href: reference.href, reason: "not-found" };
+ }
+ if (
+ typeof rawPullRequest !== "object" ||
+ rawPullRequest === null ||
+ !Array.isArray(rawReviews)
+ ) {
+ throw new Error("GitHub metadata response was unavailable.");
+ }
+
+ const response = rawPullRequest as GithubResponse;
+ if (
+ typeof response.title !== "string" ||
+ response.number !== reference.number
+ ) {
+ throw new Error("GitHub metadata response was invalid.");
+ }
+
+ return {
+ status: "live",
+ provider: "github",
+ href: reference.href,
+ repository: `${reference.owner}/${reference.repository}`,
+ number: reference.number,
+ title: response.title,
+ lifecycle: lifecycleFromGithubResponse(response),
+ reviewState: reviewState(rawReviews, response.requested_reviewers),
+ };
+}
+
+/** Consume the existing NIP-34 root/status event streams for one Buzz PR. */
+export async function fetchBuzzPullRequestStatus(
+ reference: Extract,
+ fetchEvents: EventFetcher = (filter) => relayClient.fetchEvents(filter),
+): Promise {
+ const roots = await fetchEvents({
+ kinds: [KIND_GIT_PULL_REQUEST],
+ ids: [reference.eventId],
+ "#a": [reference.repoAddress],
+ limit: 1,
+ });
+ const root = roots.find(
+ (event) => event.id.toLowerCase() === reference.eventId,
+ );
+ if (!root) {
+ return { status: "unknown", href: reference.href, reason: "not-found" };
+ }
+
+ const [updates, comments, statuses] = await Promise.all([
+ fetchEvents({ kinds: [1634], "#a": [reference.repoAddress], limit: 500 }),
+ fetchEvents({ kinds: [1], "#a": [reference.repoAddress], limit: 500 }),
+ fetchEvents({
+ kinds: NIP34_STATUS_KINDS,
+ "#a": [reference.repoAddress],
+ limit: 500,
+ }),
+ ]);
+ const pullRequest = eventToProjectPullRequest(
+ root,
+ updates,
+ comments,
+ statuses,
+ );
+ return {
+ status: "live",
+ provider: "buzz",
+ href: reference.href,
+ repository: reference.repository,
+ number: null,
+ title: pullRequest.title,
+ lifecycle:
+ pullRequest.status.toLowerCase() as WorkstreamPullRequestStatus["lifecycle"],
+ reviewState:
+ pullRequest.changeRequests.length > 0
+ ? "changes-requested"
+ : pullRequest.approvals.length > 0
+ ? "approved"
+ : pullRequest.reviewers.length > 0
+ ? "review-requested"
+ : "no-reviews",
+ };
+}
+
+type PollerListener = (state: WorkstreamPullRequestDisplayState) => void;
+type PollerReference = Exclude<
+ WorkstreamPullRequestReference,
+ { kind: "unsupported" }
+>;
+type PollerEntry = {
+ reference: PollerReference;
+ listeners: Set;
+ state: WorkstreamPullRequestDisplayState;
+ controller: AbortController | null;
+ timer: ReturnType | null;
+ startedAt: number;
+ pollCount: number;
+};
+
+export type WorkstreamPollingRegistryOptions = {
+ fetcher?: GithubFetch;
+ fetchEvents?: EventFetcher;
+ intervalMs?: number;
+ maxPolls?: number;
+ maxLifetimeMs?: number;
+ now?: () => number;
+ schedule?: (
+ callback: () => void,
+ delayMs: number,
+ ) => ReturnType;
+ cancel?: (timer: ReturnType) => void;
+};
+
+/** One bounded, route-scoped metadata stream per canonical PR identity. */
+export function createWorkstreamPollingRegistry(
+ options: WorkstreamPollingRegistryOptions = {},
+) {
+ const githubFetcher = options.fetcher ?? fetch;
+ const fetchEvents =
+ options.fetchEvents ?? ((filter) => relayClient.fetchEvents(filter));
+ const intervalMs = options.intervalMs ?? DEFAULT_GITHUB_POLL_INTERVAL_MS;
+ const maxPolls = options.maxPolls ?? MAX_GITHUB_POLLS;
+ const maxLifetimeMs =
+ options.maxLifetimeMs ?? DEFAULT_GITHUB_POLL_LIFETIME_MS;
+ const now = options.now ?? (() => Date.now());
+ const schedule =
+ options.schedule ?? ((callback, delay) => setTimeout(callback, delay));
+ const cancel = options.cancel ?? ((timer) => clearTimeout(timer));
+ const entries = new Map();
+
+ const stop = (entry: PollerEntry) => {
+ if (entry.timer !== null) cancel(entry.timer);
+ entry.timer = null;
+ entry.controller?.abort();
+ entry.controller = null;
+ entries.delete(entry.reference.identity);
+ };
+
+ const notify = (entry: PollerEntry) => {
+ for (const listener of entry.listeners) listener(entry.state);
+ };
+
+ const fetchStatus = (entry: PollerEntry, signal: AbortSignal) =>
+ entry.reference.kind === "github"
+ ? fetchGithubPullRequestStatus(entry.reference, {
+ fetcher: githubFetcher,
+ signal,
+ })
+ : fetchBuzzPullRequestStatus(entry.reference, fetchEvents);
+
+ const poll = async (entry: PollerEntry) => {
+ if (!entries.has(entry.reference.identity) || entry.listeners.size === 0)
+ return;
+ const controller = new AbortController();
+ entry.controller = controller;
+ try {
+ entry.state = await fetchStatus(entry, controller.signal);
+ notify(entry);
+ } catch {
+ if (controller.signal.aborted && entry.listeners.size === 0) return;
+ entry.state = { status: "error", href: entry.reference.href };
+ notify(entry);
+ } finally {
+ entry.controller = null;
+ entry.pollCount += 1;
+ const bounded =
+ entry.pollCount >= maxPolls || now() - entry.startedAt >= maxLifetimeMs;
+ if (entry.listeners.size > 0 && !bounded) {
+ entry.timer = schedule(() => {
+ entry.timer = null;
+ void poll(entry);
+ }, intervalMs);
+ }
+ }
+ };
+
+ const subscribe = (reference: PollerReference, listener: PollerListener) => {
+ let entry = entries.get(reference.identity);
+ if (!entry) {
+ entry = {
+ reference,
+ listeners: new Set(),
+ state: { status: "loading" },
+ controller: null,
+ timer: null,
+ startedAt: now(),
+ pollCount: 0,
+ };
+ entries.set(reference.identity, entry);
+ }
+ entry.listeners.add(listener);
+ if (
+ entry.pollCount === 0 &&
+ entry.controller === null &&
+ entry.timer === null
+ ) {
+ void poll(entry);
+ }
+ listener(entry.state);
+ return () => {
+ if (!entry?.listeners.delete(listener)) return;
+ if (entry.listeners.size === 0) stop(entry);
+ };
+ };
+
+ return {
+ subscribe,
+ entryCount: () => entries.size,
+ reset: () => {
+ for (const entry of [...entries.values()]) stop(entry);
+ },
+ };
+}
+
+const defaultWorkstreamPollingRegistry = createWorkstreamPollingRegistry();
+
+type WorkstreamPollingRegistry = Pick<
+ ReturnType,
+ "subscribe"
+>;
+
+export function useWorkstreamPullRequestStatuses(
+ references: readonly WorkstreamPullRequestReference[],
+ pollingRegistry: WorkstreamPollingRegistry = defaultWorkstreamPollingRegistry,
+): ReadonlyMap {
+ const [states, setStates] = React.useState<
+ ReadonlyMap
+ >(() => new Map());
+ const referencesByIdentity = React.useMemo(() => {
+ const next = new Map();
+ for (const reference of references) next.set(reference.identity, reference);
+ return next;
+ }, [references]);
+
+ React.useEffect(() => {
+ const initial = new Map();
+ for (const reference of referencesByIdentity.values()) {
+ initial.set(
+ reference.identity,
+ reference.kind === "unsupported"
+ ? {
+ status: "unsupported",
+ href: reference.href,
+ reason: reference.reason,
+ }
+ : { status: "loading" },
+ );
+ }
+ setStates(initial);
+
+ const unsubscribers: Array<() => void> = [];
+ for (const reference of referencesByIdentity.values()) {
+ if (reference.kind === "unsupported") continue;
+ const unsubscribe = pollingRegistry.subscribe(reference, (state) => {
+ setStates((current) => {
+ const updated = new Map(current);
+ updated.set(reference.identity, state);
+ return updated;
+ });
+ });
+ unsubscribers.push(unsubscribe);
+ }
+ return () => {
+ for (const unsubscribe of unsubscribers) unsubscribe();
+ };
+ }, [pollingRegistry, referencesByIdentity]);
+
+ return states;
+}
+
+export const workstreamPollingLimits = {
+ intervalMs: DEFAULT_GITHUB_POLL_INTERVAL_MS,
+ maxLifetimeMs: DEFAULT_GITHUB_POLL_LIFETIME_MS,
+ maxPolls: MAX_GITHUB_POLLS,
+};
diff --git a/desktop/src/features/workstream-board/ui/WorkstreamCard.tsx b/desktop/src/features/workstream-board/ui/WorkstreamCard.tsx
index 17af849991..3f297cab26 100644
--- a/desktop/src/features/workstream-board/ui/WorkstreamCard.tsx
+++ b/desktop/src/features/workstream-board/ui/WorkstreamCard.tsx
@@ -1,12 +1,15 @@
+import * as React from "react";
import { Hash } from "lucide-react";
import type { ActiveChannelTurnSummary } from "@/features/agents/activeAgentTurnsStore";
import { useCanvasQuery } from "@/features/channels/hooks";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
+import { parseWorkstreamPullRequestReferences } from "@/features/workstream-board/lib/workstreamPullRequestStatus";
import { buildWorkstreamCardViewModel } from "@/features/workstream-board/lib/workstreamCardViewModel";
+import { WorkstreamPullRequests } from "@/features/workstream-board/ui/WorkstreamPullRequests";
+import { WorkstreamWorkingIndicator } from "@/features/workstream-board/ui/WorkstreamWorkingIndicator";
import type { Channel } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
-import { WorkstreamWorkingIndicator } from "@/features/workstream-board/ui/WorkstreamWorkingIndicator";
type WorkstreamCardProps = {
activeWorking?: ActiveChannelTurnSummary;
@@ -27,6 +30,13 @@ export function WorkstreamCard({
isLoading: canvasQuery.isLoading,
isError: canvasQuery.isError,
});
+ const references = React.useMemo(
+ () =>
+ viewModel.status === "ready"
+ ? parseWorkstreamPullRequestReferences(viewModel.card.pullRequests)
+ : [],
+ [viewModel],
+ );
return (
) : null}
+ {references.length > 0 ? (
+
+ ) : null}
>
) : viewModel.status === "loading" ? (
diff --git a/desktop/src/features/workstream-board/ui/WorkstreamPullRequests.tsx b/desktop/src/features/workstream-board/ui/WorkstreamPullRequests.tsx
new file mode 100644
index 0000000000..a1ee317686
--- /dev/null
+++ b/desktop/src/features/workstream-board/ui/WorkstreamPullRequests.tsx
@@ -0,0 +1,108 @@
+import { ExternalLink, GitPullRequest } from "lucide-react";
+import { openUrl } from "@tauri-apps/plugin-opener";
+
+import { parseEntityLink } from "@/shared/lib/entityLink";
+import { useOpenEntityLink } from "@/shared/ui/markdown/entityLinks";
+import {
+ type WorkstreamPullRequestDisplayState,
+ type WorkstreamPullRequestReference,
+ useWorkstreamPullRequestStatuses,
+} from "@/features/workstream-board/lib/workstreamPullRequestStatus";
+
+function lifecycleLabel(
+ state: Extract,
+) {
+ return state.lifecycle[0].toUpperCase() + state.lifecycle.slice(1);
+}
+
+function reviewLabel(
+ state: Extract,
+) {
+ switch (state.reviewState) {
+ case "approved":
+ return "Approved";
+ case "changes-requested":
+ return "Changes requested";
+ case "review-requested":
+ return "Review requested";
+ default:
+ return "No reviews";
+ }
+}
+
+function referenceLabel(reference: WorkstreamPullRequestReference) {
+ if (reference.kind === "github") {
+ return `${reference.owner}/${reference.repository} #${reference.number}`;
+ }
+ if (reference.kind === "buzz") {
+ return `${reference.repository} · ${reference.eventId.slice(0, 8)}`;
+ }
+ return reference.rawUrl;
+}
+
+export function WorkstreamPullRequests({
+ references,
+}: {
+ references: readonly WorkstreamPullRequestReference[];
+}) {
+ const states = useWorkstreamPullRequestStatuses(references);
+ const openEntityLink = useOpenEntityLink();
+
+ return (
+
+
+ Pull requests
+
+ {references.map((reference) => {
+ const state = states.get(reference.identity);
+ const label = referenceLabel(reference);
+ const parsedBuzzLink =
+ reference.kind === "buzz" ? parseEntityLink(reference.href) : null;
+ const open = () => {
+ if (parsedBuzzLink?.ok) {
+ // Keep Buzz PRs in the existing canonical entity navigation path.
+ openEntityLink(parsedBuzzLink.value);
+ return;
+ }
+ void openUrl(reference.href);
+ };
+
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 1f7b147749..d96eb46ab9 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -13345,6 +13345,29 @@ export function maybeInstallE2eTauriMocks() {
if (canvasReadError) {
throw new Error(canvasReadError);
}
+ if (isRelayMode(activeConfig)) {
+ const channelId = (payload as { channelId?: string }).channelId;
+ if (!channelId) {
+ throw new Error("get_canvas requires channelId");
+ }
+ const events = await relayQuery(activeConfig, [
+ { kinds: [40100], "#h": [channelId], limit: 1 },
+ ]);
+ const event = events[0];
+ return event
+ ? {
+ content: event.content,
+ event_id: event.id,
+ updated_at: event.created_at,
+ author: event.pubkey,
+ }
+ : {
+ content: "",
+ event_id: null,
+ updated_at: null,
+ author: null,
+ };
+ }
// Return the no-canvas success shape — content null means no canvas set.
return { content: null, updated_at: null, author: null };
}
diff --git a/desktop/tests/e2e/get-canvas-bridge.spec.ts b/desktop/tests/e2e/get-canvas-bridge.spec.ts
new file mode 100644
index 0000000000..b50ed52471
--- /dev/null
+++ b/desktop/tests/e2e/get-canvas-bridge.spec.ts
@@ -0,0 +1,79 @@
+import { expect, test } from "@playwright/test";
+
+import { installMockBridge, installRelayBridge } from "../helpers/bridge";
+
+async function invokeCanvas(
+ page: import("@playwright/test").Page,
+ channelId: string,
+) {
+ return page.evaluate(async (id) => {
+ const tauriWindow = window as Window & {
+ __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
+ command: string,
+ payload?: Record,
+ ) => Promise;
+ __TAURI_INTERNALS__?: {
+ invoke?: (
+ command: string,
+ payload?: Record,
+ ) => Promise;
+ };
+ };
+ const invoke =
+ tauriWindow.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ ??
+ tauriWindow.__TAURI_INTERNALS__?.invoke;
+ if (!invoke) throw new Error("Mock invoke bridge is unavailable.");
+ return invoke("get_canvas", { channelId: id });
+ }, channelId);
+}
+
+test("relay mode forwards get_canvas to the authenticated kind-40100 query", async ({
+ page,
+}) => {
+ await installRelayBridge(page, "tyler");
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.waitForFunction(
+ () =>
+ typeof (window as Window & { __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown })
+ .__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function",
+ );
+
+ const result = (await invokeCanvas(
+ page,
+ "eb4d4cee-2b29-4d53-addf-e6c4b93b4c0d",
+ )) as { content?: string | null; event_id?: string | null };
+ expect(result.event_id).toBeTruthy();
+ expect(result.content).toContain("B3 linked PR");
+});
+
+test("mock mode keeps the deterministic null canvas response", async ({
+ page,
+}) => {
+ await installMockBridge(page);
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.waitForFunction(
+ () =>
+ typeof (window as Window & { __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown })
+ .__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function",
+ );
+
+ await expect(await invokeCanvas(page, "mock-channel")).toEqual({
+ content: null,
+ updated_at: null,
+ author: null,
+ });
+});
+
+test("mock mode keeps configured canvas errors isolated", async ({ page }) => {
+ await installMockBridge(page, { canvasReadError: "canvas unavailable" });
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.waitForFunction(
+ () =>
+ typeof (window as Window & { __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown })
+ .__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function",
+ );
+
+ await expect(invokeCanvas(page, "mock-channel")).rejects.toThrow(
+ "canvas unavailable",
+ );
+});
diff --git a/desktop/tests/e2e/workstream-board-b3-pr.capture.spec.ts b/desktop/tests/e2e/workstream-board-b3-pr.capture.spec.ts
new file mode 100644
index 0000000000..91d7526ad8
--- /dev/null
+++ b/desktop/tests/e2e/workstream-board-b3-pr.capture.spec.ts
@@ -0,0 +1,116 @@
+import { expect, test } from "@playwright/test";
+
+import { installRelayBridge } from "../helpers/bridge";
+
+test("continuous relay-backed Workstream Board bullet 3 PR-status demo", async ({
+ page,
+}, testInfo) => {
+ test.setTimeout(120_000);
+ await installRelayBridge(page, "tyler", { seedPreviewFeatures: true });
+ await page.routeWebSocket(/localhost:3000/, (socket) => {
+ const server = socket.connectToServer();
+ let delayedPrSubscription: string | null = null;
+ let delayedFrames: string[] = [];
+ let releaseScheduled = false;
+ const release = () => {
+ const frames = delayedFrames;
+ delayedFrames = [];
+ delayedPrSubscription = null;
+ releaseScheduled = false;
+ for (const frame of frames) socket.send(frame);
+ };
+ server.onMessage((message) => {
+ if (typeof message === "string") {
+ try {
+ const payload = JSON.parse(message) as unknown[];
+ const type = payload[0];
+ const subId = typeof payload[1] === "string" ? payload[1] : null;
+ const event = payload[2] as { kind?: unknown } | undefined;
+ if (type === "EVENT" && event?.kind === 1618 && subId) {
+ delayedPrSubscription = subId;
+ delayedFrames.push(message);
+ if (!releaseScheduled) {
+ releaseScheduled = true;
+ setTimeout(release, 2_500);
+ }
+ return;
+ }
+ if (type === "EOSE" && subId === delayedPrSubscription) {
+ delayedFrames.push(message);
+ return;
+ }
+ } catch {
+ // Pass non-JSON relay frames through unchanged.
+ }
+ }
+ socket.send(message);
+ });
+ socket.onMessage((message) => server.send(message));
+ });
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await expect(page.getByTestId("app-sidebar")).toBeVisible();
+ await expect(page.getByTestId("open-workstream-board-view")).toBeVisible();
+ await page.getByTestId("open-workstream-board-view").click();
+
+ const board = page.getByTestId("workstream-board-view");
+ await expect(board).toBeVisible();
+ const channelId = "eb4d4cee-2b29-4d53-addf-e6c4b93b4c0d";
+ // Focused bridge regression: relay mode must return the real kind-40100
+ // canvas event. Mock mode intentionally keeps the deterministic null/error
+ // behavior covered by relay-connectivity.spec.ts.
+ const canvas = (await page.evaluate(async (id) => {
+ const tauriWindow = window as Window & {
+ __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
+ command: string,
+ payload?: Record,
+ ) => Promise;
+ __TAURI_INTERNALS__?: {
+ invoke?: (
+ command: string,
+ payload?: Record,
+ ) => Promise;
+ };
+ };
+ const invoke =
+ tauriWindow.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ ??
+ tauriWindow.__TAURI_INTERNALS__?.invoke;
+ if (!invoke) throw new Error("Mock invoke bridge is unavailable.");
+ return invoke("get_canvas", { channelId: id });
+ }, channelId)) as { content?: string | null };
+ expect(canvas.content).toContain("B3 linked PR");
+
+ const card = page.getByTestId(`workstream-card-${channelId}`);
+ await expect(card).toBeVisible();
+ await expect(card).toContainText(
+ "Show a real linked PR loading then live status",
+ );
+
+ const pullRequest = card.getByTestId("workstream-pull-request");
+ await expect(pullRequest).toBeVisible();
+ await expect(pullRequest).toContainText("Loading…");
+ await page.waitForTimeout(1_000);
+ await page.screenshot({
+ path: testInfo.outputPath("b3-loading.png"),
+ fullPage: true,
+ });
+
+ await expect(pullRequest).toContainText("B3 linked PR");
+ await expect(pullRequest).toContainText("Open");
+ await expect(pullRequest).toContainText("No reviews");
+ await page.screenshot({
+ path: testInfo.outputPath("b3-live.png"),
+ fullPage: true,
+ });
+ await page.waitForTimeout(3_000);
+
+ await pullRequest.click();
+ await expect(page).toHaveURL(/\/projects\//);
+ await expect(
+ page.locator("[data-project-detail-panel]:visible").first(),
+ ).toBeVisible({ timeout: 15_000 });
+ await page.screenshot({
+ path: testInfo.outputPath("b3-canonical-open.png"),
+ fullPage: true,
+ });
+ await page.waitForTimeout(3_000);
+});