Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/desktop/src/lib/trpc/routers/team-dashboard.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
buildActivity,
buildRoster,
buildRosterGitHub,
buildWorkBoard,
type TeamWorkspaceRef,
} from "@papyrus/server-core/team-dashboard";
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -77,7 +80,7 @@ export function AgentCard({ entry }: AgentCardProps) {

<div className="flex items-center justify-between gap-2">
<AgentStatusBadge status={entry.status} />
{entry.pr && (
{entry.pr ? (
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
<span
className={cn(
Expand All @@ -87,7 +90,17 @@ export function AgentCard({ entry }: AgentCardProps) {
/>
<span className="font-mono tabular-nums">#{entry.pr.number}</span>
</span>
)}
) : prPending ? (
// Reserve the PR slot while the GitHub overlay resolves so the
// badge hydrating in later doesn't shift the row (issue #65).
<span
aria-hidden
className="flex items-center gap-1 text-[11px] text-muted-foreground/50"
>
<span className="size-1.5 shrink-0 animate-pulse rounded-full bg-muted-foreground/20" />
<span className="font-mono tabular-nums opacity-0">#0000</span>
</span>
) : null}
</div>

{session && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<section className="space-y-3">
<h2 className="text-sm font-medium text-muted-foreground">Team</h2>
Expand All @@ -29,7 +31,11 @@ export function RosterHero({ entries, isLoading }: RosterHeroProps) {
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{entries.map((entry) => (
<AgentCard key={entry.workspaceId} entry={entry} />
<AgentCard
key={entry.workspaceId}
entry={entry}
prPending={isPRLoading}
/>
))}
</div>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export function TeamDashboard({ projectId }: TeamDashboardProps) {
activity,
board,
isRosterLoading,
isRosterGitHubLoading,
isActivityLoading,
isBoardLoading,
} = useTeamDashboard(projectId);
Expand All @@ -29,7 +30,11 @@ export function TeamDashboard({ projectId }: TeamDashboardProps) {
<div className="mx-auto max-w-[1400px] px-6 py-6">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_320px]">
<div className="min-w-0 space-y-6">
<RosterHero entries={roster} isLoading={isRosterLoading} />
<RosterHero
entries={roster}
isLoading={isRosterLoading}
isPRLoading={isRosterGitHubLoading}
/>
<WorkBoard board={board} isLoading={isBoardLoading} />
</div>
<div className="min-w-0">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,34 @@ 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;
iconUrl: string | null;
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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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 },
Expand Down Expand Up @@ -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<string, RosterPROverlay>();
for (const row of (rosterGitHubQuery.data ?? []) as RosterPROverlay[]) {
map.set(row.workspaceId, row);
}
return map;
}, [rosterGitHubQuery.data]);

const roster = useMemo<RosterEntry[]>(() => {
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;
Expand All @@ -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,
};
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/routers/team-dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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({
Expand Down
117 changes: 112 additions & 5 deletions packages/server-core/src/team-dashboard.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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> = {}): 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);
}
});
});
Loading
Loading