fix: simplify kanban pr footer

This commit is contained in:
Aditi Chauhan 2026-07-03 16:26:38 +05:30
parent cc75a519ab
commit b5bb803203
10 changed files with 204 additions and 83 deletions

View File

@ -11,7 +11,7 @@ on:
workflow_dispatch:
inputs:
important:
description: 'Mark this nightly as an important update (escalates the in-app restart prompt). Retro-flag an already-published nightly by re-running only the publish-feed job with this input set.'
description: "Mark this nightly as an important update (escalates the in-app restart prompt). Retro-flag an already-published nightly by re-running only the publish-feed job with this input set."
type: boolean
default: false

View File

@ -38,20 +38,10 @@ function respondWithProjectAndPRs() {
projectId: "proj-1",
displayName: "fix the bug",
harness: "claude-code",
status: "pr_open",
status: "draft",
isTerminated: false,
updatedAt: "2026-06-10T16:15:04Z",
prs: [
{
number: 278,
state: "open",
url: "https://github.com/aoagents/ReverbCode/pull/278",
ci: "passing",
review: "approved",
mergeability: "clean",
reviewComments: false,
updatedAt: "2026-06-10T16:15:04Z",
},
{
number: 279,
state: "draft",
@ -62,6 +52,46 @@ function respondWithProjectAndPRs() {
reviewComments: false,
updatedAt: "2026-06-10T16:20:04Z",
},
{
number: 278,
state: "open",
url: "https://github.com/aoagents/ReverbCode/pull/278",
ci: "passing",
review: "review_required",
mergeability: "clean",
reviewComments: false,
updatedAt: "2026-06-10T16:15:04Z",
},
{
number: 280,
state: "open",
url: "https://github.com/aoagents/ReverbCode/issues/280",
ci: "passing",
review: "approved",
mergeability: "clean",
reviewComments: false,
updatedAt: "2026-06-10T16:25:04Z",
},
{
number: 281,
state: "merged",
url: "https://github.com/aoagents/ReverbCode/pull/281",
ci: "passing",
review: "approved",
mergeability: "mergeable",
reviewComments: false,
updatedAt: "2026-06-10T16:30:04Z",
},
{
number: 282,
state: "closed",
url: "https://github.com/aoagents/ReverbCode/pull/282",
ci: "passing",
review: "approved",
mergeability: "unknown",
reviewComments: false,
updatedAt: "2026-06-10T16:35:04Z",
},
],
},
],
@ -179,11 +209,40 @@ beforeEach(() => {
});
describe("PR hydration for a normal project (#251)", () => {
it("renders every session PR on the Board card instead of 'no PR yet'", async () => {
it("renders Board card PR numbers with lifecycle statuses only instead of 'no PR yet'", async () => {
renderWithProviders(<SessionsBoard />);
expect(await screen.findByText("PR #278 · open")).toBeInTheDocument();
expect(screen.getByText("PR #279 · draft")).toBeInTheDocument();
expect(await screen.findByRole("link", { name: "#278" })).toHaveAttribute(
"href",
"https://github.com/aoagents/ReverbCode/pull/278",
);
expect(screen.getByText("open")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "#279" })).toHaveAttribute(
"href",
"https://github.com/aoagents/ReverbCode/pull/279",
);
expect(screen.getByRole("link", { name: "#280" })).toHaveAttribute(
"href",
"https://github.com/aoagents/ReverbCode/pull/280",
);
expect(screen.getByRole("link", { name: "#281" })).toHaveAttribute(
"href",
"https://github.com/aoagents/ReverbCode/pull/281",
);
expect(screen.getByRole("link", { name: "#282" })).toHaveAttribute(
"href",
"https://github.com/aoagents/ReverbCode/pull/282",
);
expect(screen.getByLabelText("#278, #280 open")).toBeInTheDocument();
expect(screen.getByLabelText("#279 draft")).toBeInTheDocument();
expect(screen.getByLabelText("#281 merged")).toBeInTheDocument();
expect(screen.getByLabelText("#282 closed")).toBeInTheDocument();
expect(screen.getByText("draft")).toBeInTheDocument();
expect(screen.getByText("merged")).toHaveClass("text-accent");
expect(screen.getByText("closed")).toHaveClass("text-error");
expect(screen.queryByText("review pending")).not.toBeInTheDocument();
expect(screen.queryByText("CI")).not.toBeInTheDocument();
expect(screen.queryByText("Needs attention")).not.toBeInTheDocument();
expect(screen.queryByText("no PR yet")).not.toBeInTheDocument();
});
@ -191,14 +250,13 @@ describe("PR hydration for a normal project (#251)", () => {
respondWithAttentionPR();
renderWithProviders(<SessionsBoard />);
expect(await screen.findByRole("link", { name: "PR" })).toHaveAttribute(
expect(await screen.findByRole("link", { name: "#278" })).toHaveAttribute(
"href",
"https://github.com/aoagents/ReverbCode/pull/278",
);
expect(screen.getByRole("link", { name: "conflicts" })).toHaveAttribute(
"href",
"https://github.com/aoagents/ReverbCode/pull/278/conflicts",
);
expect(screen.getByText("open")).toBeInTheDocument();
expect(screen.queryByText("changes requested")).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: "conflicts" })).not.toBeInTheDocument();
});
it("lists every session PR on the PR page instead of being empty", async () => {
@ -206,7 +264,10 @@ describe("PR hydration for a normal project (#251)", () => {
expect(await screen.findByText("#278")).toBeInTheDocument();
expect(screen.getByText("#279")).toBeInTheDocument();
expect(screen.getByText("#280")).toBeInTheDocument();
expect(screen.getByText("#281")).toBeInTheDocument();
expect(screen.getByText("#282")).toBeInTheDocument();
expect(screen.queryByText("No open pull requests.")).not.toBeInTheDocument();
expect(screen.getAllByText("fix the bug")).toHaveLength(2);
expect(screen.getAllByText("fix the bug")).toHaveLength(5);
});
});

View File

@ -41,13 +41,18 @@ export function OrchestratorReplacementDialog({
<AlertTriangle className="size-4" aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<Dialog.Title className="text-sm font-medium text-foreground">Orchestrator replacement failed</Dialog.Title>
<Dialog.Title className="text-sm font-medium text-foreground">
Orchestrator replacement failed
</Dialog.Title>
<Dialog.Description className="mt-2 text-[13px] leading-5 text-muted-foreground">
{error ?? "The project orchestrator could not be replaced."}
</Dialog.Description>
</div>
<Dialog.Close asChild>
<button className="rounded-md p-1 text-passive hover:bg-interactive-hover hover:text-foreground" type="button">
<button
className="rounded-md p-1 text-passive hover:bg-interactive-hover hover:text-foreground"
type="button"
>
<X className="size-4" aria-hidden="true" />
<span className="sr-only">Close</span>
</button>

View File

@ -95,7 +95,11 @@ beforeEach(() => {
putMock.mockReset();
postMock.mockReset();
putMock.mockResolvedValue({ data: { project: {} }, error: undefined });
postMock.mockResolvedValue({ data: { orchestrator: { id: "proj-1-orch-2" } }, error: undefined, response: { status: 200 } });
postMock.mockResolvedValue({
data: { orchestrator: { id: "proj-1-orch-2" } },
error: undefined,
response: { status: 200 },
});
});
describe("ProjectSettingsForm", () => {

View File

@ -18,9 +18,8 @@ import { OrchestratorIcon } from "./icons";
import { NewTaskDialog } from "./NewTaskDialog";
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
import { restartProjectOrchestrator } from "../lib/restart-orchestrator";
import { prDiffSummary, sessionPRDisplaySummaries } from "../lib/pr-display";
import { prBrowserUrl, sessionPRDisplaySummaries } from "../lib/pr-display";
import { cn } from "../lib/utils";
import { PRAttentionPanel, PRStatusStrip } from "./PRSummaryDisplay";
import { useUiStore } from "../stores/ui-store";
type SessionsBoardProps = {
@ -169,7 +168,13 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
type="button"
>
<OrchestratorIcon className="h-3.5 w-3.5" aria-hidden="true" />
{isProjectRestarting ? "Restarting..." : isSpawning ? "Spawning..." : orchestrator ? "Orchestrator" : "Spawn Orchestrator"}
{isProjectRestarting
? "Restarting..."
: isSpawning
? "Spawning..."
: orchestrator
? "Orchestrator"
: "Spawn Orchestrator"}
</button>
</>
) : undefined;
@ -314,73 +319,103 @@ function SessionCard({ session, onOpen }: { session: WorkspaceSession; onOpen: (
onOpen();
};
return (
<div
className="w-full rounded-[7px] border border-border bg-surface text-left transition-colors hover:border-border-strong"
onClick={onOpen}
onKeyDown={handleKeyDown}
role="button"
tabIndex={0}
>
<div className="flex items-center gap-2 px-[13px] pb-[9px] pt-3">
<span className={cn("inline-flex items-center gap-1.5 text-[11px] font-medium", badge.className)}>
<span className={cn("h-[7px] w-[7px] rounded-full bg-current")} />
{badge.label}
</span>
{issueId && (
<span
className="inline-flex max-w-[13rem] items-center truncate rounded-[4px] bg-[color-mix(in_srgb,var(--accent)_12%,transparent)] px-1.5 py-0.5 font-mono text-[10px] text-accent"
title={`Intake issue: ${issueId}`}
>
{issueId}
<div className="w-full rounded-[7px] border border-border bg-surface text-left transition-colors hover:border-border-strong">
<div onClick={onOpen} onKeyDown={handleKeyDown} role="button" tabIndex={0}>
<div className="flex items-center gap-2 px-[13px] pb-[9px] pt-3">
<span className={cn("inline-flex items-center gap-1.5 text-[11px] font-medium", badge.className)}>
<span className={cn("h-[7px] w-[7px] rounded-full bg-current")} />
{badge.label}
</span>
)}
<span className="ml-auto shrink-0 font-mono text-[10.5px] tracking-[0.04em] text-passive">
{agentLabel(session.provider)}
</span>
{issueId && (
<span
className="inline-flex max-w-[13rem] items-center truncate rounded-[4px] bg-[color-mix(in_srgb,var(--accent)_12%,transparent)] px-1.5 py-0.5 font-mono text-[10px] text-accent"
title={`Intake issue: ${issueId}`}
>
{issueId}
</span>
)}
<span className="ml-auto shrink-0 font-mono text-[10.5px] tracking-[0.04em] text-passive">
{agentLabel(session.provider)}
</span>
</div>
<div
className={cn(
"px-[13px] text-[13px] font-medium leading-[1.42] tracking-[-0.01em] text-foreground",
showBranch ? "pb-2" : "pb-3",
"line-clamp-2 overflow-hidden",
)}
>
{session.title}
</div>
{showBranch && <div className="px-[13px] pb-2.5 font-mono text-[10.5px] text-passive">{branch}</div>}
</div>
<div
className={cn(
"px-[13px] text-[13px] font-medium leading-[1.42] tracking-[-0.01em] text-foreground",
showBranch ? "pb-2" : "pb-3",
"line-clamp-2 overflow-hidden",
)}
className="border-t border-border px-[13px] py-2 font-mono text-[10.5px] text-passive"
onClick={(event) => event.stopPropagation()}
>
{session.title}
</div>
{showBranch && <div className="px-[13px] pb-2.5 font-mono text-[10.5px] text-passive">{branch}</div>}
<div className="border-t border-border px-[13px] py-2 font-mono text-[10.5px] text-passive">
{prSummaries.length > 0 ? (
<div className="flex flex-col gap-2">
{prSummaries.map((prSummary, index) => (
<BoardPRSummary
className={cn(index > 0 && "border-t border-border pt-2")}
key={prSummary.number}
pr={prSummary}
/>
{prSummaries.length === 0 ? (
"no PR yet"
) : (
<div className="flex flex-col gap-1">
{groupPRsByLifecycle(prSummaries).map((group) => (
<BoardPRGroup group={group} key={group.status.label} />
))}
</div>
) : (
"no PR yet"
)}
</div>
</div>
);
}
function BoardPRSummary({ className, pr }: { className?: string; pr: SessionPRSummary }) {
const diffSummary = prDiffSummary(pr);
type BoardPRLifecycleStatus = { label: "closed" | "open" | "draft" | "merged"; className: string };
type BoardPRGroup = { status: BoardPRLifecycleStatus; prs: SessionPRSummary[] };
function BoardPRGroup({ group }: { group: BoardPRGroup }) {
return (
<div className={cn("flex min-w-0 flex-col gap-1", className)}>
<span>
PR #{pr.number} · {pr.state}
</span>
{diffSummary ? <span className="truncate">{diffSummary}</span> : null}
<PRStatusStrip pr={pr} />
<PRAttentionPanel className="mt-1.5 pt-1.5" maxItems={2} pr={pr} />
</div>
<span
aria-label={`${group.prs.map((pr) => `#${pr.number}`).join(", ")} ${group.status.label}`}
className="inline-flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1"
>
<span>PR</span>
{group.prs.map((pr, index) => (
<span key={pr.number}>
<a
className="text-passive underline-offset-2 transition-colors hover:text-foreground hover:underline"
href={prBrowserUrl(pr)}
rel="noreferrer"
target="_blank"
>
#{pr.number}
</a>
{index < group.prs.length - 1 ? "," : null}
</span>
))}
<span className={cn("font-medium", group.status.className)}>{group.status.label}</span>
</span>
);
}
function groupPRsByLifecycle(prs: SessionPRSummary[]): BoardPRGroup[] {
const groups = new Map<BoardPRLifecycleStatus["label"], BoardPRGroup>();
for (const pr of prs) {
const status = prLifecycleStatus(pr);
const group = groups.get(status.label);
if (group) {
group.prs.push(pr);
} else {
groups.set(status.label, { status, prs: [pr] });
}
}
return Array.from(groups.values());
}
function prLifecycleStatus(pr: SessionPRSummary): BoardPRLifecycleStatus {
if (pr.state === "draft") return { label: "draft", className: "text-passive" };
if (pr.state === "merged") return { label: "merged", className: "text-accent" };
if (pr.state === "closed") return { label: "closed", className: "text-error" };
return { label: "open", className: "text-success" };
}
function sameLabel(a: string, b: string): boolean {
const normalize = (value: string) =>
value

View File

@ -557,7 +557,13 @@ function ProjectItem({
</button>
</TooltipTrigger>
<TooltipContent>
{isProjectRestarting ? "Restarting…" : isSpawning ? "Spawning…" : orchestrator ? "Orchestrator" : "Spawn orchestrator"}
{isProjectRestarting
? "Restarting…"
: isSpawning
? "Spawning…"
: orchestrator
? "Orchestrator"
: "Spawn orchestrator"}
</TooltipContent>
</Tooltip>
<DropdownMenu>

View File

@ -101,7 +101,12 @@ describe("useWorkspaceQuery", () => {
await waitFor(() => expect(result.current.isSuccess).toBe(true));
const [workspace] = result.current.data ?? [];
expect(workspace).toMatchObject({ id: "proj-1", name: "my-app", path: "/home/me/my-app", orchestratorAgent: "codex" });
expect(workspace).toMatchObject({
id: "proj-1",
name: "my-app",
path: "/home/me/my-app",
orchestratorAgent: "codex",
});
expect(workspace.sessions).toHaveLength(2);
expect(workspace.sessions[0]).toMatchObject({
id: "sess-1",

View File

@ -44,7 +44,10 @@ export async function restartProjectOrchestrator({
});
} catch (error) {
await refreshWorkspaceState(queryClient);
setOrchestratorReplacementError(projectId, error instanceof Error ? error.message : "Could not replace orchestrator");
setOrchestratorReplacementError(
projectId,
error instanceof Error ? error.message : "Could not replace orchestrator",
);
onError?.(error);
} finally {
setProjectRestarting(projectId, false);

View File

@ -239,7 +239,8 @@ describe("orchestratorHealth", () => {
}),
).toEqual({
state: "duplicates",
message: "Multiple orchestrators are active. The newest one is used; stale ones will be cleaned up on daemon reconcile.",
message:
"Multiple orchestrators are active. The newest one is used; stale ones will be cleaned up on daemon reconcile.",
});
expect(

View File

@ -386,7 +386,8 @@ export function orchestratorHealth(workspace: WorkspaceSummary, restarting = fal
if (active.length > 1) {
return {
state: "duplicates",
message: "Multiple orchestrators are active. The newest one is used; stale ones will be cleaned up on daemon reconcile.",
message:
"Multiple orchestrators are active. The newest one is used; stale ones will be cleaned up on daemon reconcile.",
};
}
const orchestrator = newestActiveOrchestrator(workspace.sessions);