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