-
+
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