fix: simplify kanban pr footer
This commit is contained in:
parent
cc75a519ab
commit
b5bb803203
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue