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: workflow_dispatch:
inputs: inputs:
important: 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 type: boolean
default: false default: false

View File

@ -38,20 +38,10 @@ function respondWithProjectAndPRs() {
projectId: "proj-1", projectId: "proj-1",
displayName: "fix the bug", displayName: "fix the bug",
harness: "claude-code", harness: "claude-code",
status: "pr_open", status: "draft",
isTerminated: false, isTerminated: false,
updatedAt: "2026-06-10T16:15:04Z", updatedAt: "2026-06-10T16:15:04Z",
prs: [ 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, number: 279,
state: "draft", state: "draft",
@ -62,6 +52,46 @@ function respondWithProjectAndPRs() {
reviewComments: false, reviewComments: false,
updatedAt: "2026-06-10T16:20:04Z", 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)", () => { 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 />); renderWithProviders(<SessionsBoard />);
expect(await screen.findByText("PR #278 · open")).toBeInTheDocument(); expect(await screen.findByRole("link", { name: "#278" })).toHaveAttribute(
expect(screen.getByText("PR #279 · draft")).toBeInTheDocument(); "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(); expect(screen.queryByText("no PR yet")).not.toBeInTheDocument();
}); });
@ -191,14 +250,13 @@ describe("PR hydration for a normal project (#251)", () => {
respondWithAttentionPR(); respondWithAttentionPR();
renderWithProviders(<SessionsBoard />); renderWithProviders(<SessionsBoard />);
expect(await screen.findByRole("link", { name: "PR" })).toHaveAttribute( expect(await screen.findByRole("link", { name: "#278" })).toHaveAttribute(
"href", "href",
"https://github.com/aoagents/ReverbCode/pull/278", "https://github.com/aoagents/ReverbCode/pull/278",
); );
expect(screen.getByRole("link", { name: "conflicts" })).toHaveAttribute( expect(screen.getByText("open")).toBeInTheDocument();
"href", expect(screen.queryByText("changes requested")).not.toBeInTheDocument();
"https://github.com/aoagents/ReverbCode/pull/278/conflicts", expect(screen.queryByRole("link", { name: "conflicts" })).not.toBeInTheDocument();
);
}); });
it("lists every session PR on the PR page instead of being empty", async () => { 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(await screen.findByText("#278")).toBeInTheDocument();
expect(screen.getByText("#279")).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.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" /> <AlertTriangle className="size-4" aria-hidden="true" />
</div> </div>
<div className="min-w-0 flex-1"> <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"> <Dialog.Description className="mt-2 text-[13px] leading-5 text-muted-foreground">
{error ?? "The project orchestrator could not be replaced."} {error ?? "The project orchestrator could not be replaced."}
</Dialog.Description> </Dialog.Description>
</div> </div>
<Dialog.Close asChild> <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" /> <X className="size-4" aria-hidden="true" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</button> </button>

View File

@ -95,7 +95,11 @@ beforeEach(() => {
putMock.mockReset(); putMock.mockReset();
postMock.mockReset(); postMock.mockReset();
putMock.mockResolvedValue({ data: { project: {} }, error: undefined }); 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", () => { describe("ProjectSettingsForm", () => {

View File

@ -18,9 +18,8 @@ import { OrchestratorIcon } from "./icons";
import { NewTaskDialog } from "./NewTaskDialog"; import { NewTaskDialog } from "./NewTaskDialog";
import { spawnOrchestrator } from "../lib/spawn-orchestrator"; import { spawnOrchestrator } from "../lib/spawn-orchestrator";
import { restartProjectOrchestrator } from "../lib/restart-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 { cn } from "../lib/utils";
import { PRAttentionPanel, PRStatusStrip } from "./PRSummaryDisplay";
import { useUiStore } from "../stores/ui-store"; import { useUiStore } from "../stores/ui-store";
type SessionsBoardProps = { type SessionsBoardProps = {
@ -169,7 +168,13 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
type="button" type="button"
> >
<OrchestratorIcon className="h-3.5 w-3.5" aria-hidden="true" /> <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> </button>
</> </>
) : undefined; ) : undefined;
@ -314,13 +319,8 @@ function SessionCard({ session, onOpen }: { session: WorkspaceSession; onOpen: (
onOpen(); onOpen();
}; };
return ( return (
<div <div className="w-full rounded-[7px] border border-border bg-surface text-left transition-colors hover:border-border-strong">
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}>
onClick={onOpen}
onKeyDown={handleKeyDown}
role="button"
tabIndex={0}
>
<div className="flex items-center gap-2 px-[13px] pb-[9px] pt-3"> <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("inline-flex items-center gap-1.5 text-[11px] font-medium", badge.className)}>
<span className={cn("h-[7px] w-[7px] rounded-full bg-current")} /> <span className={cn("h-[7px] w-[7px] rounded-full bg-current")} />
@ -348,39 +348,74 @@ function SessionCard({ session, onOpen }: { session: WorkspaceSession; onOpen: (
{session.title} {session.title}
</div> </div>
{showBranch && <div className="px-[13px] pb-2.5 font-mono text-[10.5px] text-passive">{branch}</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"> </div>
{prSummaries.length > 0 ? ( <div
<div className="flex flex-col gap-2"> className="border-t border-border px-[13px] py-2 font-mono text-[10.5px] text-passive"
{prSummaries.map((prSummary, index) => ( onClick={(event) => event.stopPropagation()}
<BoardPRSummary >
className={cn(index > 0 && "border-t border-border pt-2")} {prSummaries.length === 0 ? (
key={prSummary.number} "no PR yet"
pr={prSummary} ) : (
/> <div className="flex flex-col gap-1">
{groupPRsByLifecycle(prSummaries).map((group) => (
<BoardPRGroup group={group} key={group.status.label} />
))} ))}
</div> </div>
) : (
"no PR yet"
)} )}
</div> </div>
</div> </div>
); );
} }
function BoardPRSummary({ className, pr }: { className?: string; pr: SessionPRSummary }) { type BoardPRLifecycleStatus = { label: "closed" | "open" | "draft" | "merged"; className: string };
const diffSummary = prDiffSummary(pr); type BoardPRGroup = { status: BoardPRLifecycleStatus; prs: SessionPRSummary[] };
function BoardPRGroup({ group }: { group: BoardPRGroup }) {
return ( return (
<div className={cn("flex min-w-0 flex-col gap-1", className)}> <span
<span> aria-label={`${group.prs.map((pr) => `#${pr.number}`).join(", ")} ${group.status.label}`}
PR #{pr.number} · {pr.state} 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> </span>
{diffSummary ? <span className="truncate">{diffSummary}</span> : null}
<PRStatusStrip pr={pr} />
<PRAttentionPanel className="mt-1.5 pt-1.5" maxItems={2} pr={pr} />
</div>
); );
} }
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 { function sameLabel(a: string, b: string): boolean {
const normalize = (value: string) => const normalize = (value: string) =>
value value

View File

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

View File

@ -101,7 +101,12 @@ describe("useWorkspaceQuery", () => {
await waitFor(() => expect(result.current.isSuccess).toBe(true)); await waitFor(() => expect(result.current.isSuccess).toBe(true));
const [workspace] = result.current.data ?? []; 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).toHaveLength(2);
expect(workspace.sessions[0]).toMatchObject({ expect(workspace.sessions[0]).toMatchObject({
id: "sess-1", id: "sess-1",

View File

@ -44,7 +44,10 @@ export async function restartProjectOrchestrator({
}); });
} catch (error) { } catch (error) {
await refreshWorkspaceState(queryClient); 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); onError?.(error);
} finally { } finally {
setProjectRestarting(projectId, false); setProjectRestarting(projectId, false);

View File

@ -239,7 +239,8 @@ describe("orchestratorHealth", () => {
}), }),
).toEqual({ ).toEqual({
state: "duplicates", 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( expect(

View File

@ -386,7 +386,8 @@ export function orchestratorHealth(workspace: WorkspaceSummary, restarting = fal
if (active.length > 1) { if (active.length > 1) {
return { return {
state: "duplicates", 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); const orchestrator = newestActiveOrchestrator(workspace.sessions);