From 52110f267d31788cb945b9ce33b4cb2c43ac1511 Mon Sep 17 00:00:00 2001 From: Cameron Crow <157651944+CameronCrow@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:34:42 -0500 Subject: [PATCH] fix(dashboard): first paint from local data, never block on GitHub (#65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The team dashboard's roster blocked first paint on GitHub-backed PR queries whose gh/git spawns cost 10-35s on slow-DNS networks. Split the roster so the local half (session activity/stats from JSONL on disk) paints immediately and the PR column hydrates when the slow half arrives. server-core (team-dashboard.ts): - buildRoster is now LOCAL ONLY (activity + stats); pr is null and status comes from activity alone, so an entry is never "blocked" pre-overlay. - New buildRosterGitHub returns a per-workspace RosterPROverlay (PR + checks), gated by an env delay hook (PAPYRUS_DASHBOARD_GH_DELAY_MS) so the acceptance test can simulate a 30s-slow GitHub without touching the shared github-team module. - New pure applyRosterOverlay merges the overlay onto the local roster; deriveRosterStatus precedence unchanged (failing PR => blocked only once the overlay is present; missing overlay => activity status). routers: mirror a rosterGitHub procedure in both the desktop (publicProcedure) and server (authedProcedure) teamDashboard routers. client: useTeamDashboard fires roster (5s) + rosterGitHub (30s) independently, renders RosterHero from the local query, then merges the PR overlay (blocked wins over the live pane overlay). AgentCard reserves the PR slot while the overlay loads so hydration causes no layout shift. ActivityRail/WorkBoard already render per-panel placeholders and never gate mount (verified — no change needed). Tests: extend team-dashboard.test.ts for applyRosterOverlay precedence and the delay-hook env parsing (10/10 pass). Co-Authored-By: Claude Fable 5 --- .../src/lib/trpc/routers/team-dashboard.ts | 9 ++ .../-components/team-dashboard/AgentCard.tsx | 19 ++- .../-components/team-dashboard/RosterHero.tsx | 10 +- .../team-dashboard/TeamDashboard.tsx | 7 +- .../-components/team-dashboard/types.ts | 24 ++- .../team-dashboard/useTeamDashboard.ts | 50 ++++++- apps/server/src/routers/team-dashboard.ts | 7 + .../server-core/src/team-dashboard.test.ts | 117 ++++++++++++++- packages/server-core/src/team-dashboard.ts | 137 ++++++++++++++---- 9 files changed, 333 insertions(+), 47 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/team-dashboard.ts b/apps/desktop/src/lib/trpc/routers/team-dashboard.ts index 348efaa5..5768f5e8 100644 --- a/apps/desktop/src/lib/trpc/routers/team-dashboard.ts +++ b/apps/desktop/src/lib/trpc/routers/team-dashboard.ts @@ -1,6 +1,7 @@ import { buildActivity, buildRoster, + buildRosterGitHub, buildWorkBoard, type TeamWorkspaceRef, } from "@papyrus/server-core/team-dashboard"; @@ -35,6 +36,14 @@ export const createTeamDashboardRouter = () => { buildRoster(toWorkspaceRefs(getWorkspacesByProjectId(input.projectId))), ), + rosterGitHub: publicProcedure + .input(z.object({ projectId: z.string() })) + .query(({ input }) => + buildRosterGitHub( + toWorkspaceRefs(getWorkspacesByProjectId(input.projectId)), + ), + ), + activity: publicProcedure .input( z.object({ diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/project/$projectId/-components/team-dashboard/AgentCard.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/project/$projectId/-components/team-dashboard/AgentCard.tsx index 1ac60435..892783f4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/project/$projectId/-components/team-dashboard/AgentCard.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/project/$projectId/-components/team-dashboard/AgentCard.tsx @@ -40,9 +40,12 @@ function sessionLine(session: RosterEntry["session"]): string | null { interface AgentCardProps { entry: RosterEntry; + /** GitHub PR overlay still loading (issue #65): reserve the PR slot so the + * badge hydrating in later causes no layout shift. */ + prPending?: boolean; } -export function AgentCard({ entry }: AgentCardProps) { +export function AgentCard({ entry, prPending }: AgentCardProps) { const navigate = useNavigate(); const session = sessionLine(entry.session); @@ -77,7 +80,7 @@ export function AgentCard({ entry }: AgentCardProps) {
- {entry.pr && ( + {entry.pr ? ( #{entry.pr.number} - )} + ) : prPending ? ( + // Reserve the PR slot while the GitHub overlay resolves so the + // badge hydrating in later doesn't shift the row (issue #65). + + + #0000 + + ) : null}
{session && ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/project/$projectId/-components/team-dashboard/RosterHero.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/project/$projectId/-components/team-dashboard/RosterHero.tsx index ba21165e..284c613a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/project/$projectId/-components/team-dashboard/RosterHero.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/project/$projectId/-components/team-dashboard/RosterHero.tsx @@ -5,13 +5,15 @@ import type { RosterEntry } from "./types"; interface RosterHeroProps { entries: RosterEntry[]; isLoading: boolean; + /** The GitHub PR overlay is still resolving; AgentCard reserves the PR slot. */ + isPRLoading?: boolean; } /** * Header row of the dashboard: one card per agent on the project. This is the * "who's on the team and what are they doing right now" glance. */ -export function RosterHero({ entries, isLoading }: RosterHeroProps) { +export function RosterHero({ entries, isLoading, isPRLoading }: RosterHeroProps) { return (

Team

@@ -29,7 +31,11 @@ export function RosterHero({ entries, isLoading }: RosterHeroProps) { ) : (
{entries.map((entry) => ( - + ))}
)} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/project/$projectId/-components/team-dashboard/TeamDashboard.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/project/$projectId/-components/team-dashboard/TeamDashboard.tsx index 6fe03547..f6b7ac11 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/project/$projectId/-components/team-dashboard/TeamDashboard.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/project/$projectId/-components/team-dashboard/TeamDashboard.tsx @@ -19,6 +19,7 @@ export function TeamDashboard({ projectId }: TeamDashboardProps) { activity, board, isRosterLoading, + isRosterGitHubLoading, isActivityLoading, isBoardLoading, } = useTeamDashboard(projectId); @@ -29,7 +30,11 @@ export function TeamDashboard({ projectId }: TeamDashboardProps) {
- +
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/project/$projectId/-components/team-dashboard/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/project/$projectId/-components/team-dashboard/types.ts index d0bec027..cda0aa1a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/project/$projectId/-components/team-dashboard/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/project/$projectId/-components/team-dashboard/types.ts @@ -15,6 +15,13 @@ export type AgentStatus = export type ChecksStatus = "success" | "failure" | "pending" | "none"; +export interface RosterPR { + number: number; + title: string; + url: string; + checksStatus: ChecksStatus; +} + export interface RosterEntry { workspaceId: string; name: string; @@ -22,15 +29,20 @@ export interface RosterEntry { branch: string | null; status: AgentStatus; session: { model: string | null; contextTokens: number | null } | null; - pr: { - number: number; - title: string; - url: string; - checksStatus: ChecksStatus; - } | null; + pr: RosterPR | null; lastActivityAt: number | null; } +/** + * The GitHub-backed PR overlay for one roster entry (issue #65). Served by the + * separate `rosterGitHub` procedure so the local roster paints before the slow + * `gh`/`git` reads resolve, then merged onto it client-side. + */ +export interface RosterPROverlay { + workspaceId: string; + pr: RosterPR | null; +} + export type ActivityKind = | "pr-opened" | "pr-merged" diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/project/$projectId/-components/team-dashboard/useTeamDashboard.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/project/$projectId/-components/team-dashboard/useTeamDashboard.ts index 2fda153a..68f8d97f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/project/$projectId/-components/team-dashboard/useTeamDashboard.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/project/$projectId/-components/team-dashboard/useTeamDashboard.ts @@ -6,12 +6,16 @@ import type { ActivityEvent, AgentStatus, RosterEntry, + RosterPROverlay, WorkBoardData, } from "./types"; /** Poll cadences (issue #51). Roster is the "live" surface, so it refreshes the * fastest; the board changes slowly, so it refreshes the slowest. */ const ROSTER_POLL_MS = 5_000; +/** The GitHub PR overlay (issue #65) is the slow half — gated by the 2.5min + * server-side cache anyway — so it polls much slower than the live local roster. */ +const ROSTER_GITHUB_POLL_MS = 30_000; const ACTIVITY_POLL_MS = 15_000; const WORKBOARD_POLL_MS = 30_000; @@ -30,12 +34,21 @@ const EMPTY_BOARD: WorkBoardData = { todo: [], doing: [], done: [] }; * - pane "working" -> "working" * Server "blocked" ALWAYS wins over the overlay (a CI/PR failure the local pane * has no knowledge of). "review"/"idle" panes never override the server status. + * + * First paint never blocks on GitHub (issue #65): the `roster` query is local + * only (session activity/stats from disk) and drives RosterHero immediately. The + * slower `rosterGitHub` query fills in the PR column / blocked status when it + * resolves — a missing overlay leaves an entry's status from activity alone. */ export function useTeamDashboard(projectId: string) { const rosterQuery = electronTrpc.teamDashboard.roster.useQuery( { projectId }, { enabled: !!projectId, refetchInterval: ROSTER_POLL_MS }, ); + const rosterGitHubQuery = electronTrpc.teamDashboard.rosterGitHub.useQuery( + { projectId }, + { enabled: !!projectId, refetchInterval: ROSTER_GITHUB_POLL_MS }, + ); const activityQuery = electronTrpc.teamDashboard.activity.useQuery( { projectId, limit: 30 }, { enabled: !!projectId, refetchInterval: ACTIVITY_POLL_MS }, @@ -75,16 +88,40 @@ export function useTeamDashboard(projectId: string) { return overlay; }, [tabs, panes]); + // GitHub PR overlay (issue #65), keyed by workspace. Mirrors server-core's + // pure `applyRosterOverlay`: when a row is present its PR is applied and a + // failing check surfaces as "blocked"; a missing row leaves the entry as the + // local roster built it (status from activity alone, never "blocked"). + const prByWorkspace = useMemo(() => { + const map = new Map(); + for (const row of (rosterGitHubQuery.data ?? []) as RosterPROverlay[]) { + map.set(row.workspaceId, row); + } + return map; + }, [rosterGitHubQuery.data]); + const roster = useMemo(() => { const entries = (rosterQuery.data ?? []) as RosterEntry[]; return entries.map((entry) => { - // Server "blocked" always wins over the client overlay. - if (entry.status === "blocked") return entry; + // 1. GitHub PR overlay. Only a present row changes the entry; a failing + // PR is the sole source of "blocked". + const overlay = prByWorkspace.get(entry.workspaceId); + const withPr: RosterEntry = overlay + ? { + ...entry, + pr: overlay.pr, + status: + overlay.pr?.checksStatus === "failure" ? "blocked" : entry.status, + } + : entry; + + // 2. Live pane-status overlay. Server/GitHub "blocked" always wins. + if (withPr.status === "blocked") return withPr; const override = overlayByWorkspace.get(entry.workspaceId); - if (!override) return entry; - return { ...entry, status: override }; + if (!override) return withPr; + return { ...withPr, status: override }; }); - }, [rosterQuery.data, overlayByWorkspace]); + }, [rosterQuery.data, prByWorkspace, overlayByWorkspace]); const activity = (activityQuery.data ?? []) as ActivityEvent[]; const board = (workBoardQuery.data ?? EMPTY_BOARD) as WorkBoardData; @@ -94,6 +131,9 @@ export function useTeamDashboard(projectId: string) { activity, board, isRosterLoading: rosterQuery.isLoading, + // The PR column is still pending until the GitHub overlay first resolves — + // AgentCard uses this to reserve the PR slot (no layout shift on hydrate). + isRosterGitHubLoading: rosterGitHubQuery.isLoading, isActivityLoading: activityQuery.isLoading, isBoardLoading: workBoardQuery.isLoading, }; diff --git a/apps/server/src/routers/team-dashboard.ts b/apps/server/src/routers/team-dashboard.ts index 013cbe83..783c48f0 100644 --- a/apps/server/src/routers/team-dashboard.ts +++ b/apps/server/src/routers/team-dashboard.ts @@ -4,6 +4,7 @@ import { localDb } from "@papyrus/server-core/local-db"; import { buildActivity, buildRoster, + buildRosterGitHub, buildWorkBoard, type TeamWorkspaceRef, } from "@papyrus/server-core/team-dashboard"; @@ -44,6 +45,12 @@ export const teamDashboardRouter = router({ .input(z.object({ projectId: z.string() })) .query(({ input }) => buildRoster(resolveWorkspaceRefs(input.projectId))), + rosterGitHub: authedProcedure + .input(z.object({ projectId: z.string() })) + .query(({ input }) => + buildRosterGitHub(resolveWorkspaceRefs(input.projectId)), + ), + activity: authedProcedure .input( z.object({ diff --git a/packages/server-core/src/team-dashboard.test.ts b/packages/server-core/src/team-dashboard.test.ts index d52d2571..ce8ad03e 100644 --- a/packages/server-core/src/team-dashboard.test.ts +++ b/packages/server-core/src/team-dashboard.test.ts @@ -1,10 +1,18 @@ -import { describe, expect, it } from "bun:test"; -import { deriveRosterStatus } from "./team-dashboard"; +import { afterEach, describe, expect, it } from "bun:test"; +import { + applyRosterOverlay, + deriveRosterStatus, + type RosterEntry, + type RosterPROverlay, + rosterGitHubDelayMs, +} from "./team-dashboard"; /** - * team-dashboard unit tests (issue #51, unit U5). Only the pure status-precedence - * seam is exercised here — buildRoster/buildActivity/buildWorkBoard reach out to - * the filesystem and `gh`, which isn't worth mocking for v1. + * team-dashboard unit tests. The pure status-precedence seam (deriveRosterStatus) + * plus the issue #65 first-paint split: the roster is built from local data only, + * and the GitHub PR half arrives separately as an overlay that's merged in via the + * pure `applyRosterOverlay`. buildRoster/buildRosterGitHub themselves reach out to + * the filesystem and `gh`, which isn't worth mocking here. */ describe("deriveRosterStatus", () => { it("blocks when PR checks are failing, regardless of activity", () => { @@ -21,3 +29,102 @@ describe("deriveRosterStatus", () => { expect(deriveRosterStatus("unknown", null)).toBe("unknown"); }); }); + +// A local-only roster entry as buildRoster now produces it (issue #65): status +// from activity alone, PR column not yet populated. +function localEntry(overrides: Partial = {}): RosterEntry { + return { + workspaceId: "ws1", + name: "Agent One", + iconUrl: null, + branch: "feat/x", + status: "working", + session: { model: "claude-opus-4-8", contextTokens: 1234 }, + pr: null, + lastActivityAt: 1000, + ...overrides, + }; +} + +describe("applyRosterOverlay (issue #65 first-paint split)", () => { + it("leaves an entry untouched when the overlay has no row for it", () => { + const roster = [localEntry()]; + const merged = applyRosterOverlay(roster, []); + expect(merged[0]).toEqual(roster[0]); + // A missing overlay must never fabricate "blocked" — status from activity. + expect(merged[0].status).toBe("working"); + expect(merged[0].pr).toBeNull(); + }); + + it("hydrates the PR column without changing status when checks pass", () => { + const overlay: RosterPROverlay[] = [ + { + workspaceId: "ws1", + pr: { number: 42, title: "T", url: "u", checksStatus: "success" }, + }, + ]; + const merged = applyRosterOverlay([localEntry({ status: "waiting" })], overlay); + expect(merged[0].pr).toEqual(overlay[0].pr); + expect(merged[0].status).toBe("waiting"); + }); + + it("promotes to blocked only once a failing overlay is present", () => { + const entry = localEntry({ status: "working" }); + // Before the overlay: not blocked. + expect(applyRosterOverlay([entry], [])[0].status).toBe("working"); + // After a failing overlay: blocked. + const overlay: RosterPROverlay[] = [ + { + workspaceId: "ws1", + pr: { number: 7, title: "T", url: "u", checksStatus: "failure" }, + }, + ]; + expect(applyRosterOverlay([entry], overlay)[0].status).toBe("blocked"); + }); + + it("applies a null-PR overlay row (agent has no PR) without blocking", () => { + const overlay: RosterPROverlay[] = [{ workspaceId: "ws1", pr: null }]; + const merged = applyRosterOverlay([localEntry({ status: "idle" })], overlay); + expect(merged[0].pr).toBeNull(); + expect(merged[0].status).toBe("idle"); + }); + + it("matches overlay rows to entries by workspaceId", () => { + const a = localEntry({ workspaceId: "a", status: "working" }); + const b = localEntry({ workspaceId: "b", status: "idle" }); + const overlay: RosterPROverlay[] = [ + { + workspaceId: "b", + pr: { number: 9, title: "T", url: "u", checksStatus: "failure" }, + }, + ]; + const merged = applyRosterOverlay([a, b], overlay); + expect(merged[0].status).toBe("working"); // a: no overlay row + expect(merged[1].status).toBe("blocked"); // b: failing overlay + }); +}); + +describe("rosterGitHubDelayMs (issue #65 acceptance hook)", () => { + const original = process.env.PAPYRUS_DASHBOARD_GH_DELAY_MS; + afterEach(() => { + if (original === undefined) delete process.env.PAPYRUS_DASHBOARD_GH_DELAY_MS; + else process.env.PAPYRUS_DASHBOARD_GH_DELAY_MS = original; + }); + + it("is 0 when the env var is unset", () => { + delete process.env.PAPYRUS_DASHBOARD_GH_DELAY_MS; + expect(rosterGitHubDelayMs()).toBe(0); + }); + + it("parses a positive integer delay", () => { + process.env.PAPYRUS_DASHBOARD_GH_DELAY_MS = "30000"; + expect(rosterGitHubDelayMs()).toBe(30000); + }); + + it("ignores non-positive or non-numeric values", () => { + for (const bad of ["0", "-5", "abc", ""]) { + process.env.PAPYRUS_DASHBOARD_GH_DELAY_MS = bad; + expect(rosterGitHubDelayMs()).toBe(0); + } + }); +}); diff --git a/packages/server-core/src/team-dashboard.ts b/packages/server-core/src/team-dashboard.ts index fc14ce78..300fa11b 100644 --- a/packages/server-core/src/team-dashboard.ts +++ b/packages/server-core/src/team-dashboard.ts @@ -1,5 +1,4 @@ import { - type AgentActivity, readLatestSessionActivity, readLatestSessionStats, } from "./claude-sessions"; @@ -38,6 +37,13 @@ export type TeamWorkspaceRef = { agentHome: string | null; }; +export type RosterPR = { + number: number; + title: string; + url: string; + checksStatus: "success" | "failure" | "pending" | "none"; +}; + export type RosterEntry = { workspaceId: string; name: string; @@ -45,15 +51,20 @@ export type RosterEntry = { branch: string | null; status: "working" | "waiting" | "blocked" | "idle" | "unknown"; session: { model: string | null; contextTokens: number | null } | null; - pr: { - number: number; - title: string; - url: string; - checksStatus: "success" | "failure" | "pending" | "none"; - } | null; + pr: RosterPR | null; lastActivityAt: number | null; }; +/** + * The GitHub-backed overlay for a single roster entry (issue #65). Returned by + * the separate `rosterGitHub` procedure so the local roster can paint before the + * slow `gh`/`git` spawns resolve. One entry per workspace, keyed by workspaceId. + */ +export type RosterPROverlay = { + workspaceId: string; + pr: RosterPR | null; +}; + /** * Combine an agent's live-session activity with its PR checks into the roster * status. A failing PR ("blocked") outranks everything; otherwise the raw @@ -61,7 +72,7 @@ export type RosterEntry = { * precedence is unit-testable without the async filesystem/gh reads. */ export function deriveRosterStatus( - activityStatus: AgentActivity["status"], + activityStatus: RosterEntry["status"], checksStatus: string | null, ): RosterEntry["status"] { if (checksStatus === "failure") return "blocked"; @@ -69,10 +80,16 @@ export function deriveRosterStatus( } /** - * Build the per-agent roster: for each workspace, its live session activity, - * model/context stats, and PR status, collapsed into one row. Per-workspace - * failures degrade that single entry to status "unknown" — they never sink the - * whole roster. + * Build the per-agent roster from LOCAL data only (issue #65): live session + * activity + model/context stats, both read from JSONL files on disk (fast). The + * GitHub-backed PR column is deliberately excluded here so first paint never + * blocks on the 10-35s `gh`/`git` spawns — that half arrives separately via + * `buildRosterGitHub` and is merged client-side (see `applyRosterOverlay`). + * + * Because there's no checks data at this stage, status comes from activity alone + * (`deriveRosterStatus(..., null)`) — an entry is never "blocked" here. Per- + * workspace failures degrade that single entry to status "unknown"; they never + * sink the whole roster. `pr` is always null until the overlay hydrates it. */ export async function buildRoster( workspaces: TeamWorkspaceRef[], @@ -80,31 +97,21 @@ export async function buildRoster( return Promise.all( workspaces.map(async (ws): Promise => { try { - const [activity, stats, prStatus] = await Promise.all([ + const [activity, stats] = await Promise.all([ readLatestSessionActivity(ws.worktreePath), readLatestSessionStats(ws.worktreePath), - fetchGitHubPRStatus(ws.worktreePath), ]); - const pr = prStatus?.pr - ? { - number: prStatus.pr.number, - title: prStatus.pr.title, - url: prStatus.pr.url, - checksStatus: prStatus.pr.checksStatus, - } - : null; - return { workspaceId: ws.workspaceId, name: ws.name, iconUrl: ws.iconUrl, branch: ws.branch, - status: deriveRosterStatus(activity.status, pr?.checksStatus ?? null), + status: deriveRosterStatus(activity.status, null), session: stats ? { model: stats.model, contextTokens: stats.contextTokens } : null, - pr, + pr: null, lastActivityAt: activity.lastModified, }; } catch { @@ -123,6 +130,86 @@ export async function buildRoster( ); } +/** + * Env-gated artificial delay (ms) applied before the roster's GitHub calls. + * Zero unless `PAPYRUS_DASHBOARD_GH_DELAY_MS` is set to a positive integer. This + * is the acceptance hook for issue #65 (simulate a slow-DNS network where `gh` + * spawns take 30s) without having to touch the shared `github-team` module — it + * lets you verify the local roster still paints in <1s while the PR overlay lags. + */ +export function rosterGitHubDelayMs(): number { + const raw = process.env.PAPYRUS_DASHBOARD_GH_DELAY_MS; + if (!raw) return 0; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : 0; +} + +/** + * Build the GitHub-backed PR overlay for the roster (issue #65): one + * `RosterPROverlay` per workspace, resolved from `fetchGitHubPRStatus`. This is + * the slow half — it's a separate procedure so `buildRoster` (local) can paint + * first and this hydrates the PR column / blocked status when it arrives. A + * per-workspace failure degrades that entry to `pr: null`; it never throws. + */ +export async function buildRosterGitHub( + workspaces: TeamWorkspaceRef[], +): Promise { + const delay = rosterGitHubDelayMs(); + if (delay > 0) { + await new Promise((resolve) => setTimeout(resolve, delay)); + } + + return Promise.all( + workspaces.map(async (ws): Promise => { + try { + const prStatus = await fetchGitHubPRStatus(ws.worktreePath); + const pr = prStatus?.pr + ? { + number: prStatus.pr.number, + title: prStatus.pr.title, + url: prStatus.pr.url, + checksStatus: prStatus.pr.checksStatus, + } + : null; + return { workspaceId: ws.workspaceId, pr }; + } catch { + return { workspaceId: ws.workspaceId, pr: null }; + } + }), + ); +} + +/** + * Merge the GitHub PR overlay onto a locally-built roster (issue #65). Pure and + * synchronous so the precedence is unit-testable. For each entry: + * - if the overlay has a row for that workspace, its `pr` is applied (may be + * null → the agent genuinely has no PR); + * - `deriveRosterStatus` then recomputes status against the (possibly new) + * checks data, so a failing PR surfaces as "blocked" only once the overlay + * is present. A missing overlay row leaves the entry exactly as the local + * roster built it (status from activity alone — never "blocked"). + * + * The desktop client mirrors this merge in `useTeamDashboard`; this canonical + * version is what the server-core unit tests pin the precedence against. + */ +export function applyRosterOverlay( + roster: RosterEntry[], + overlay: RosterPROverlay[], +): RosterEntry[] { + const byWorkspace = new Map(); + for (const o of overlay) byWorkspace.set(o.workspaceId, o); + + return roster.map((entry) => { + const row = byWorkspace.get(entry.workspaceId); + if (!row) return entry; + return { + ...entry, + pr: row.pr, + status: deriveRosterStatus(entry.status, row.pr?.checksStatus ?? null), + }; + }); +} + /** * Build the merged activity feed: a team-wide GitHub snapshot (all workspaces * share one repo, so the first workspace's worktree is the repo path) plus the