feat(web): improve task PR status hover (#2393)

This commit is contained in:
José Almeida
2026-08-10 10:06:01 +01:00
committed by GitHub
parent ed04825bb2
commit a381f5bda9
13 changed files with 1001 additions and 68 deletions
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import type { TaskPR } from "@/lib/types/github";
import { getPRStatusColor, getPRTooltip } from "./pr-task-icon";
import { getPRStatusColor } from "./pr-task-icon";
import { derivePRTaskStatusSummary } from "./pr-task-status-summary";
function draftPR(): TaskPR {
return {
@@ -40,8 +41,8 @@ describe("draft PR task status", () => {
});
it("identifies the draft without claiming it is ready to merge", () => {
const tooltip = getPRTooltip(draftPR());
expect(tooltip).toContain("Draft");
expect(tooltip).not.toContain("Ready to merge");
const summary = derivePRTaskStatusSummary(draftPR(), false);
expect(summary.rows.at(-1)).toEqual({ kind: "merge", status: "draft", tone: "muted" });
expect(summary.rows.some((row) => row.status === "ready")).toBe(false);
});
});
@@ -4,7 +4,6 @@ import {
getPRAggregateStatusColor,
areAllOpenPRsReadyToMerge,
getPRStatusColor,
getPRTooltip,
isPRAwaitingReview,
isPRReadyToMerge,
isPRWaitingOnBranchProtection,
@@ -657,33 +656,3 @@ describe("pickDefaultPR", () => {
expect(pickDefaultPR([merged, closed])?.id).toBe("merged");
});
});
describe("getPRTooltip", () => {
it("includes 'Ready to merge' when ready", () => {
const pr = makePR({
state: "open",
review_state: "approved",
checks_state: "success",
mergeable_state: "clean",
});
expect(getPRTooltip(pr)).toContain("Ready to merge");
});
it("includes 'Mergeable: blocked' when blocked", () => {
const pr = makePR({
state: "open",
review_state: "approved",
checks_state: "success",
mergeable_state: "blocked",
});
expect(getPRTooltip(pr)).toContain("Mergeable: blocked");
expect(getPRTooltip(pr)).not.toContain("Ready to merge");
});
it("omits mergeable when state is empty or unknown", () => {
const empty = makePR({ state: "open", mergeable_state: "" });
const unknown = makePR({ state: "open", mergeable_state: "unknown" });
expect(getPRTooltip(empty)).not.toContain("Mergeable:");
expect(getPRTooltip(unknown)).not.toContain("Mergeable:");
});
});
+75 -33
View File
@@ -2,9 +2,12 @@
import { IconGitPullRequest } from "@tabler/icons-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useState, type FocusEvent, type PointerEvent } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { useAppStore } from "@/components/state-provider";
import type { TaskPR } from "@/lib/types/github";
import { derivePRTaskStatusSummary, PRTaskStatusSummary } from "./pr-task-status-summary";
const MUTED_FOREGROUND = "text-muted-foreground";
const PURPLE_500 = "text-purple-500";
@@ -166,21 +169,6 @@ export function getPRStatusColor(pr: TaskPR): string {
return MUTED_FOREGROUND;
}
export function getPRTooltip(pr: TaskPR): string {
const parts = [`PR #${pr.pr_number}: ${pr.pr_title}`];
if (pr.state !== "open") parts.push(`State: ${pr.state}`);
if (pr.review_state) parts.push(`Review: ${pr.review_state}`);
if (pr.checks_state) parts.push(`CI: ${pr.checks_state}`);
if (isPRDraft(pr)) {
parts.push("Draft");
} else if (isPRReadyToMerge(pr)) {
parts.push("Ready to merge");
} else if (pr.mergeable_state && pr.mergeable_state !== "unknown" && pr.state === "open") {
parts.push(`Mergeable: ${pr.mergeable_state}`);
}
return parts.join(" | ");
}
/**
* Picks the most attention-worthy color across N PRs. For multi-repo tasks one
* red PR should dominate the visual even if the others are green. Terminal
@@ -256,52 +244,106 @@ export function PRTaskIcon({ taskId }: { taskId: string }) {
return <MultiPRIcon taskId={taskId} prs={prs} />;
}
function usePRTaskTooltipState() {
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const [dismissed, setDismissed] = useState(false);
return {
open: !dismissed && (hovered || focused),
onPointerEnter(event: PointerEvent<HTMLSpanElement>) {
if (event.pointerType !== "mouse") return;
setHovered(true);
setDismissed(false);
},
onPointerLeave(event: PointerEvent<HTMLSpanElement>) {
if (event.pointerType !== "mouse") return;
setHovered(false);
if (!focused) setDismissed(false);
},
onFocus(event: FocusEvent<HTMLSpanElement>) {
if (!event.currentTarget.matches(":focus-visible")) return;
setFocused(true);
setDismissed(false);
},
onBlur() {
setFocused(false);
if (!hovered) setDismissed(false);
},
onEscapeKeyDown() {
setDismissed(true);
},
};
}
function SinglePRIcon({ taskId, pr }: { taskId: string; pr: TaskPR }) {
const { t } = useTranslation();
const tooltip = usePRTaskTooltipState();
const readyToMerge = isPRReadyToMerge(pr);
const summary = derivePRTaskStatusSummary(pr, readyToMerge);
return (
<Tooltip>
<Tooltip open={tooltip.open}>
<TooltipTrigger asChild>
<span
data-testid={`pr-task-icon-${taskId}`}
data-pr-state={pr.state}
data-pr-count="1"
data-pr-ready-to-merge={isPRReadyToMerge(pr) ? "true" : "false"}
data-pr-ready-to-merge={readyToMerge ? "true" : "false"}
role="img"
tabIndex={0}
aria-label={t("github:pullRequestStatus", { number: pr.pr_number })}
onPointerEnter={tooltip.onPointerEnter}
onPointerLeave={tooltip.onPointerLeave}
onFocus={tooltip.onFocus}
onBlur={tooltip.onBlur}
className={cn("inline-flex items-center shrink-0", getPRStatusColor(pr))}
>
<IconGitPullRequest className="h-3.5 w-3.5" />
<IconGitPullRequest aria-hidden="true" className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent>{getPRTooltip(pr)}</TooltipContent>
<TooltipContent
sideOffset={6}
onEscapeKeyDown={tooltip.onEscapeKeyDown}
className="w-80 max-w-[calc(100vw-1rem)] p-3"
>
<PRTaskStatusSummary summaries={[summary]} />
</TooltipContent>
</Tooltip>
);
}
function MultiPRIcon({ taskId, prs }: { taskId: string; prs: TaskPR[] }) {
const { t } = useTranslation();
const tooltip = usePRTaskTooltipState();
const aggregateColor = aggregatePRStatusColor(prs);
const allReady = areAllOpenPRsReadyToMerge(prs);
const summaries = prs.map((pr) => derivePRTaskStatusSummary(pr, isPRReadyToMerge(pr)));
return (
<Tooltip>
<Tooltip open={tooltip.open}>
<TooltipTrigger asChild>
<span
data-testid={`pr-task-icon-${taskId}`}
data-pr-count={prs.length}
data-pr-ready-to-merge={allReady ? "true" : "false"}
role="img"
tabIndex={0}
aria-label={t("github:pullRequestStatuses", { count: prs.length })}
onPointerEnter={tooltip.onPointerEnter}
onPointerLeave={tooltip.onPointerLeave}
onFocus={tooltip.onFocus}
onBlur={tooltip.onBlur}
className={cn("inline-flex items-center gap-0.5 shrink-0", aggregateColor)}
>
<IconGitPullRequest className="h-3.5 w-3.5" />
<span className="text-[9px] font-semibold leading-none">{prs.length}</span>
<IconGitPullRequest aria-hidden="true" className="h-3.5 w-3.5" />
<span className="text-[9px] font-semibold leading-none tabular-nums">{prs.length}</span>
</span>
</TooltipTrigger>
<TooltipContent>
<div className="flex flex-col gap-1 text-xs">
{prs.map((pr) => (
<div key={pr.id} className="flex items-center gap-2">
<span className={cn("inline-flex shrink-0", getPRStatusColor(pr))}>
<IconGitPullRequest className="h-3 w-3" />
</span>
<span>{getPRTooltip(pr)}</span>
</div>
))}
</div>
<TooltipContent
sideOffset={6}
onEscapeKeyDown={tooltip.onEscapeKeyDown}
className="w-80 max-w-[calc(100vw-1rem)] p-3"
>
<PRTaskStatusSummary summaries={summaries} />
</TooltipContent>
</Tooltip>
);
@@ -0,0 +1,136 @@
import { describe, expect, it } from "vitest";
import type { TaskPR } from "@/lib/types/github";
import { derivePRTaskStatusSummary } from "./pr-task-status-summary";
function makePR(overrides: Partial<TaskPR> = {}): TaskPR {
return {
id: "pr-id",
task_id: "task-id",
owner: "kdlbs",
repo: "kandev",
pr_number: 2966,
pr_url: "https://github.com/kdlbs/kandev/pull/2966",
pr_title: "Make pull request status easier to scan",
head_branch: "feat/readable-pr-summary",
base_branch: "main",
author_login: "octocat",
state: "open",
review_state: "",
checks_state: "",
mergeable_state: "",
review_count: 0,
pending_review_count: 0,
comment_count: 0,
unresolved_review_threads: 0,
checks_total: 0,
checks_passing: 0,
additions: 0,
deletions: 0,
created_at: "",
merged_at: null,
closed_at: null,
last_synced_at: null,
updated_at: "",
...overrides,
};
}
describe("structured PR task status summary", () => {
it("derives separate review, CI, and ready-to-merge rows", () => {
const pr = makePR({
review_state: "approved",
checks_state: "success",
mergeable_state: "clean",
});
const summary = derivePRTaskStatusSummary(pr, true);
expect(summary).toEqual({
number: 2966,
title: "Make pull request status easier to scan",
rows: [
{ kind: "review", status: "approved", tone: "success" },
{ kind: "ci", status: "passed", tone: "success" },
{ kind: "merge", status: "ready", tone: "success" },
],
});
});
it("preserves unknown provider values while omitting missing rows", () => {
const summary = derivePRTaskStatusSummary(
makePR({
review_state: "queued_for_review" as unknown as TaskPR["review_state"],
checks_state: "",
mergeable_state: "unknown",
}),
false,
);
expect(summary.rows).toEqual([
{
kind: "review",
status: "raw",
tone: "muted",
rawValue: "queued_for_review",
},
]);
});
it("uses a terminal state row and omits mergeability for merged PRs", () => {
const summary = derivePRTaskStatusSummary(
makePR({
state: "merged",
review_state: "approved",
checks_state: "success",
mergeable_state: "clean",
}),
false,
);
expect(summary.rows).toEqual([
{ kind: "state", status: "merged", tone: "merged" },
{ kind: "review", status: "approved", tone: "success" },
{ kind: "ci", status: "passed", tone: "success" },
]);
});
it("shows blocked as a wait instead of claiming the PR is ready", () => {
const summary = derivePRTaskStatusSummary(
makePR({
review_state: "approved",
checks_state: "success",
mergeable_state: "blocked",
}),
false,
);
expect(summary.rows.at(-1)).toEqual({
kind: "merge",
status: "blocked",
tone: "muted",
});
});
it("maps requested changes, failed CI, and conflicts to danger rows", () => {
const summary = derivePRTaskStatusSummary(
makePR({
review_state: "changes_requested",
checks_state: "failure",
mergeable_state: "dirty",
}),
false,
);
expect(summary.rows).toEqual([
{ kind: "review", status: "changes_requested", tone: "danger" },
{ kind: "ci", status: "failed", tone: "danger" },
{ kind: "merge", status: "conflicts", tone: "danger" },
]);
});
it("keeps draft precedence over a supplied ready state", () => {
const summary = derivePRTaskStatusSummary(makePR({ mergeable_state: "draft" }), true);
expect(summary.rows).toEqual([{ kind: "merge", status: "draft", tone: "muted" }]);
});
});
@@ -0,0 +1,241 @@
"use client";
import {
IconAlertTriangle,
IconCheck,
IconCircleDot,
IconClockHour4,
IconGitMerge,
IconGitPullRequest,
IconGitPullRequestDraft,
IconX,
type Icon as TablerIcon,
} from "@tabler/icons-react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import type { TaskPR } from "@/lib/types/github";
export type PRTaskSummaryRowKind = "state" | "review" | "ci" | "merge";
export type PRTaskSummaryTone = "success" | "danger" | "warning" | "info" | "merged" | "muted";
export type PRTaskSummaryStatus =
| "merged"
| "closed"
| "approved"
| "changes_requested"
| "pending_review"
| "passed"
| "failed"
| "in_progress"
| "draft"
| "ready"
| "conflicts"
| "behind"
| "blocked"
| "mergeable"
| "raw";
export type PRTaskSummaryRow = {
kind: PRTaskSummaryRowKind;
status: PRTaskSummaryStatus;
tone: PRTaskSummaryTone;
rawValue?: string;
};
export type PRTaskStatusSummaryData = {
number: number;
title: string;
rows: PRTaskSummaryRow[];
};
function rawRow(kind: PRTaskSummaryRowKind, rawValue: string): PRTaskSummaryRow {
return { kind, status: "raw", tone: "muted", rawValue };
}
function deriveStateRow(state: string): PRTaskSummaryRow | null {
if (!state || state === "open") return null;
if (state === "merged") return { kind: "state", status: "merged", tone: "merged" };
if (state === "closed") return { kind: "state", status: "closed", tone: "danger" };
return rawRow("state", state);
}
function deriveReviewRow(reviewState: string): PRTaskSummaryRow | null {
if (!reviewState) return null;
if (reviewState === "approved") {
return { kind: "review", status: "approved", tone: "success" };
}
if (reviewState === "changes_requested") {
return { kind: "review", status: "changes_requested", tone: "danger" };
}
if (reviewState === "pending") {
return { kind: "review", status: "pending_review", tone: "info" };
}
return rawRow("review", reviewState);
}
function deriveCIRow(checksState: string): PRTaskSummaryRow | null {
if (!checksState) return null;
if (checksState === "success") return { kind: "ci", status: "passed", tone: "success" };
if (checksState === "failure") return { kind: "ci", status: "failed", tone: "danger" };
if (checksState === "pending") {
return { kind: "ci", status: "in_progress", tone: "warning" };
}
return rawRow("ci", checksState);
}
function deriveMergeRow(pr: TaskPR, readyToMerge: boolean): PRTaskSummaryRow | null {
if (pr.state !== "open") return null;
if (pr.mergeable_state === "draft") {
return { kind: "merge", status: "draft", tone: "muted" };
}
if (readyToMerge) return { kind: "merge", status: "ready", tone: "success" };
if (!pr.mergeable_state || pr.mergeable_state === "unknown") return null;
if (pr.mergeable_state === "dirty") {
return { kind: "merge", status: "conflicts", tone: "danger" };
}
if (pr.mergeable_state === "behind") {
return { kind: "merge", status: "behind", tone: "warning" };
}
if (pr.mergeable_state === "blocked") {
return { kind: "merge", status: "blocked", tone: "muted" };
}
if (pr.mergeable_state === "clean") {
return { kind: "merge", status: "mergeable", tone: "muted" };
}
return rawRow("merge", pr.mergeable_state);
}
export function derivePRTaskStatusSummary(
pr: TaskPR,
readyToMerge: boolean,
): PRTaskStatusSummaryData {
const rows = [
deriveStateRow(pr.state),
deriveReviewRow(pr.review_state),
deriveCIRow(pr.checks_state),
deriveMergeRow(pr, readyToMerge),
].filter((row): row is PRTaskSummaryRow => row !== null);
return { number: pr.pr_number, title: pr.pr_title, rows };
}
const ROW_LABEL_KEYS: Record<PRTaskSummaryRowKind, string> = {
state: "github:prTaskStatusState",
review: "github:prTaskStatusReview",
ci: "github:prTaskStatusCi",
merge: "github:prTaskStatusMerge",
};
const TONE_CLASSES: Record<PRTaskSummaryTone, string> = {
success: "text-emerald-600 dark:text-emerald-400",
danger: "text-red-600 dark:text-red-400",
warning: "text-amber-600 dark:text-amber-400",
info: "text-sky-600 dark:text-sky-400",
merged: "text-purple-600 dark:text-purple-400",
muted: "text-muted-foreground",
};
const STATUS_LABEL_KEYS: Record<Exclude<PRTaskSummaryStatus, "raw">, string> = {
merged: "github:merged",
closed: "github:closed",
approved: "github:approved",
changes_requested: "github:changesRequested",
pending_review: "github:pendingReview",
passed: "github:checkBucketPassed",
failed: "github:checkBucketFailed",
in_progress: "github:checkBucketInProgress",
draft: "github:draft",
ready: "github:readyToMerge",
conflicts: "github:conflicts",
behind: "github:behindBase",
blocked: "github:blocked",
mergeable: "github:mergeable",
};
const STATUS_ICONS: Record<PRTaskSummaryStatus, TablerIcon> = {
merged: IconGitMerge,
closed: IconX,
approved: IconCheck,
changes_requested: IconX,
pending_review: IconClockHour4,
passed: IconCheck,
failed: IconX,
in_progress: IconClockHour4,
draft: IconGitPullRequestDraft,
ready: IconGitMerge,
conflicts: IconAlertTriangle,
behind: IconAlertTriangle,
blocked: IconAlertTriangle,
mergeable: IconGitMerge,
raw: IconCircleDot,
};
function getStatusText(row: PRTaskSummaryRow, t: TFunction): string {
if (row.status === "raw") return row.rawValue ?? "";
return t(STATUS_LABEL_KEYS[row.status]);
}
function SummaryStatusIcon({ status }: { status: PRTaskSummaryStatus }) {
const StatusIcon = STATUS_ICONS[status];
return <StatusIcon aria-hidden="true" className="size-3.5 shrink-0" />;
}
function SummaryRow({ row }: { row: PRTaskSummaryRow }) {
const { t } = useTranslation();
return (
<div
data-testid={`pr-task-status-${row.kind}`}
className="grid grid-cols-[min-content_minmax(0,1fr)] items-start gap-x-3"
>
<span className="text-muted-foreground">{t(ROW_LABEL_KEYS[row.kind])}</span>
<span className={cn("flex min-w-0 items-center gap-1.5 font-medium", TONE_CLASSES[row.tone])}>
<SummaryStatusIcon status={row.status} />
<span className="min-w-0 break-words">{getStatusText(row, t)}</span>
</span>
</div>
);
}
export function PRTaskStatusSummary({ summaries }: { summaries: PRTaskStatusSummaryData[] }) {
const { t } = useTranslation();
return (
<div data-testid="pr-task-status-summary" className="w-full">
{summaries.map((summary, index) => (
<section
key={`${summary.number}-${index}`}
data-testid="pr-task-status-entry"
aria-label={t("github:pullRequestStatus", { number: summary.number })}
className={cn(index > 0 && "mt-3 border-t border-border/60 pt-3")}
>
<div className="flex items-start gap-2">
<IconGitPullRequest
aria-hidden="true"
className="mt-0.5 size-4 shrink-0 text-muted-foreground"
/>
<div className="min-w-0 flex-1">
<div
data-testid="pr-task-status-number"
className="text-[11px] font-medium tabular-nums text-muted-foreground"
>
{t("github:prTaskStatusNumber", { number: summary.number })}
</div>
<div
data-testid="pr-task-status-title"
className="text-pretty break-words text-sm font-medium leading-snug text-foreground [overflow-wrap:anywhere]"
>
{summary.title}
</div>
</div>
</div>
{summary.rows.length > 0 && (
<div className="mt-2.5 space-y-1.5 pl-6">
{summary.rows.map((row) => (
<SummaryRow key={row.kind} row={row} />
))}
</div>
)}
</section>
))}
</div>
);
}
@@ -45,6 +45,14 @@ async function seedBadgeTest(
type TaskPR = NonNullable<Awaited<ReturnType<ApiClient["getTaskPR"]>>>;
function visibleTaskPRSummary(page: Page) {
return page
.locator(
'[data-slot="tooltip-content"]:not([data-state="closed"]) > [data-testid="pr-task-status-summary"]',
)
.first();
}
async function waitForTaskPRFields(
apiClient: ApiClient,
taskId: string,
@@ -346,4 +354,123 @@ test.describe("PR status badge", () => {
await expect(icon).not.toHaveClass(/text-emerald-400/);
await expect(icon).not.toHaveClass(/text-green-500/);
});
test("renders readable task PR summary", async ({ testPage, apiClient, seedData, prCapture }) => {
test.setTimeout(120_000);
const taskTitle = "Readable PR Summary Task";
const prTitle =
"Improve pull request status readability with a clear title and separate review, CI, and merge rows";
const { task } = await seedBadgeTest(
apiClient,
seedData.workspaceId,
seedData.agentProfileId,
seedData.repositoryId,
taskTitle,
);
const kanban = new KanbanPage(testPage);
await kanban.goto();
await apiClient.mockGitHubAssociateTaskPR({
task_id: task.id,
owner: "testorg",
repo: "testrepo",
pr_number: 2966,
pr_url: "https://github.com/testorg/testrepo/pull/2966",
pr_title: prTitle,
head_branch: "feat/readable-pr-summary",
base_branch: "main",
author_login: "test-user",
state: "open",
review_state: "approved",
checks_state: "success",
mergeable_state: "clean",
});
await waitForTaskPRFields(apiClient, task.id, {
state: "open",
review_state: "approved",
checks_state: "success",
mergeable_state: "clean",
});
const taskRow = testPage.getByTestId("sidebar-task-item").filter({ hasText: taskTitle });
await expect(taskRow).toBeVisible({ timeout: 15_000 });
const icon = taskRow.getByTestId(`pr-task-icon-${task.id}`);
await expect(icon).toHaveAttribute("data-pr-ready-to-merge", "true");
await icon.hover();
const summary = visibleTaskPRSummary(testPage);
await expect(summary).toBeVisible();
await expect(summary.getByTestId("pr-task-status-number")).toHaveText("PR #2966");
const title = summary.getByTestId("pr-task-status-title");
await expect(title).toHaveText(prTitle);
const reviewRow = summary.getByTestId("pr-task-status-review");
await expect(reviewRow).toContainText("Review");
await expect(reviewRow).toContainText("Approved");
const ciRow = summary.getByTestId("pr-task-status-ci");
await expect(ciRow).toContainText("CI");
await expect(ciRow).toContainText("Passed");
const mergeRow = summary.getByTestId("pr-task-status-merge");
await expect(mergeRow).toContainText("Merge");
await expect(mergeRow).toContainText("Ready to merge");
await prCapture.screenshot("readable-task-pr-summary", {
caption: "Structured pull request summary in the task sidebar",
});
const [summaryBox, titleBox] = await Promise.all([summary.boundingBox(), title.boundingBox()]);
expect(summaryBox).not.toBeNull();
expect(titleBox).not.toBeNull();
expect(titleBox!.height).toBeGreaterThan(24);
const viewport = testPage.viewportSize();
expect(viewport).not.toBeNull();
expect(summaryBox!.x).toBeGreaterThanOrEqual(0);
expect(summaryBox!.y).toBeGreaterThanOrEqual(0);
expect(summaryBox!.x + summaryBox!.width).toBeLessThanOrEqual(viewport!.width);
expect(summaryBox!.y + summaryBox!.height).toBeLessThanOrEqual(viewport!.height);
expect(
await testPage.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
),
).toBe(true);
await taskRow.focus();
await testPage.keyboard.press("Tab");
await expect(icon).toBeFocused();
const focusedSummary = visibleTaskPRSummary(testPage);
await expect(focusedSummary).toBeVisible();
await expect(focusedSummary.getByTestId("pr-task-status-title")).toHaveText(prTitle);
await testPage.mouse.move(viewport!.width - 1, viewport!.height - 1);
await expect(focusedSummary).toBeVisible();
await testPage.keyboard.press("Escape");
await expect(focusedSummary).toBeHidden();
await apiClient.mockGitHubAssociateTaskPR({
workspace_id: seedData.workspaceId,
task_id: task.id,
owner: "testorg",
repo: "api",
pr_number: 2967,
pr_url: "https://github.com/testorg/api/pull/2967",
pr_title: "Resolve the failing API checks",
head_branch: "fix/api-checks",
base_branch: "main",
author_login: "test-user",
state: "open",
review_state: "changes_requested",
checks_state: "failure",
mergeable_state: "dirty",
});
await expect(icon).toHaveAttribute("data-pr-count", "2", { timeout: 15_000 });
await icon.hover();
const multiSummary = visibleTaskPRSummary(testPage);
const entries = multiSummary.getByTestId("pr-task-status-entry");
await expect(entries).toHaveCount(2);
await expect(entries.nth(0).getByTestId("pr-task-status-number")).toHaveText("PR #2966");
await expect(entries.nth(1).getByTestId("pr-task-status-number")).toHaveText("PR #2967");
await expect(entries.nth(1).getByTestId("pr-task-status-title")).toHaveText(
"Resolve the failing API checks",
);
});
});
@@ -94,5 +94,9 @@ test.describe("Mobile task status summary", () => {
),
).toBe(true);
expect(viewport!.width).toBeGreaterThan(0);
// The passive PR icon must not steal the row's native touch target.
await targetRow.tap();
await expect(testPage).toHaveURL(new RegExp(`/t/${targetTask.task_id}$`));
});
});
+11
View File
@@ -107,6 +107,7 @@
"clientId": "Client ID",
"clientSecret": "Client secret",
"closePrStatus": "Close PR status",
"closed": "Closed",
"closedAgo": "closed {{time}}",
"commentCount_one": "{{count}} comment",
"commentCount_other": "{{count}} comments",
@@ -285,6 +286,7 @@
"managedWorkspaceCredentials": "Managed workspace credentials",
"me": "Me",
"meMyTeams": "Me & my teams",
"mergeable": "Mergeable",
"mergeConflictsWith": "Merge conflicts with",
"mergeMethodDefault": "Merge PR",
"mergeMethodMergeCommit": "Create a merge commit",
@@ -381,6 +383,11 @@
"prApproved": "PR approved",
"prClosedWithoutMerging": "PR closed without merging",
"prMerged": "PR merged",
"prTaskStatusCi": "CI",
"prTaskStatusMerge": "Merge",
"prTaskStatusNumber": "PR #{{number}}",
"prTaskStatusReview": "Review",
"prTaskStatusState": "State",
"prepareAppOnGithub": "Prepare App on GitHub",
"presetIconBug": "Bug",
"presetIconCheck": "Check",
@@ -406,6 +413,9 @@
"pullRequestCiStatusReviewsAnd": "Pull request CI status, reviews, and checks summary.",
"pullRequestCount_one": "{{count}} pull request",
"pullRequestCount_other": "{{count}} pull requests",
"pullRequestStatus": "Pull request #{{number}} status",
"pullRequestStatuses_one": "{{count}} pull request status",
"pullRequestStatuses_other": "{{count}} pull request statuses",
"pullRequests": "Pull requests",
"pullRequestsAndIssuesAcrossYour": "Pull requests and issues across your repos.",
"pullRequestsLinkedToTask_one": "{{count}} pull request linked to this task — open one",
@@ -419,6 +429,7 @@
"rateLimitUnitPoints": "points",
"rateLimitUnitRequests": "requests",
"rateLimitUnknown": "unknown",
"readyToMerge": "Ready to merge",
"reRequestReview": "Re-request review",
"reRequestReviewFrom": "Re-request review from {{author}}",
"reconnectGithub": "Reconnect GitHub",
+11
View File
@@ -107,6 +107,7 @@
"clientId": "Ćĺĩēńţ ĨĎ",
"clientSecret": "Ćĺĩēńţ śēćŕēţ",
"closePrStatus": "Ćĺōśē ƤŔ śţàţũś",
"closed": "Ćĺōśēď",
"closedAgo": "ćĺōśēď {{time}}",
"commentCount_one": "{{count}} ćōḿḿēńţ",
"commentCount_other": "{{count}} ćōḿḿēńţś",
@@ -285,6 +286,7 @@
"managedWorkspaceCredentials": "Ḿàńàĝēď ŵōŕķśƥàćē ćŕēďēńţĩàĺś",
"me": "Ḿē",
"meMyTeams": "Ḿē & ḿŷ ţēàḿś",
"mergeable": "Ḿēŕĝēàƀĺē",
"mergeConflictsWith": "Ḿēŕĝē ćōńƒĺĩćţś ŵĩţĥ",
"mergeMethodDefault": "Ḿēŕĝē ƤŔ",
"mergeMethodMergeCommit": "Ćŕēàţē à ḿēŕĝē ćōḿḿĩţ",
@@ -381,6 +383,11 @@
"prApproved": "ƤŔ àƥƥŕōvēď",
"prClosedWithoutMerging": "ƤŔ ćĺōśēď ŵĩţĥōũţ ḿēŕĝĩńĝ",
"prMerged": "ƤŔ ḿēŕĝēď",
"prTaskStatusCi": "ĆĨ",
"prTaskStatusMerge": "Ḿēŕĝē",
"prTaskStatusNumber": "ƤŔ #{{number}}",
"prTaskStatusReview": "Ŕēvĩēŵ",
"prTaskStatusState": "Śţàţē",
"prepareAppOnGithub": "Ƥŕēƥàŕē Àƥƥ ōń ĜĩţĤũƀ",
"presetIconBug": "Ɓũĝ",
"presetIconCheck": "Ćĥēćķ",
@@ -406,6 +413,9 @@
"pullRequestCiStatusReviewsAnd": "Ƥũĺĺ ŕēqũēśţ ĆĨ śţàţũś, ŕēvĩēŵś, àńď ćĥēćķś śũḿḿàŕŷ.",
"pullRequestCount_one": "{{count}} ƥũĺĺ ŕēqũēśţ",
"pullRequestCount_other": "{{count}} ƥũĺĺ ŕēqũēśţś",
"pullRequestStatus": "Ƥũĺĺ ŕēqũēśţ #{{number}} śţàţũś",
"pullRequestStatuses_one": "{{count}} ƥũĺĺ ŕēqũēśţ śţàţũś",
"pullRequestStatuses_other": "{{count}} ƥũĺĺ ŕēqũēśţ śţàţũśēś",
"pullRequests": "Ƥũĺĺ ŕēqũēśţś",
"pullRequestsAndIssuesAcrossYour": "Ƥũĺĺ ŕēqũēśţś àńď ĩśśũēś àćŕōśś ŷōũŕ ŕēƥōś.",
"pullRequestsLinkedToTask_one": "{{count}} ƥũĺĺ ŕēqũēśţ ĺĩńķēď ţō ţĥĩś ţàśķ — ōƥēń ōńē",
@@ -419,6 +429,7 @@
"rateLimitUnitPoints": "ƥōĩńţś",
"rateLimitUnitRequests": "ŕēqũēśţś",
"rateLimitUnknown": "ũńķńōŵń",
"readyToMerge": "Ŕēàďŷ ţō ḿēŕĝē",
"reRequestReview": "Ŕē-ŕēqũēśţ ŕēvĩēŵ",
"reRequestReviewFrom": "Ŕē-ŕēqũēśţ ŕēvĩēŵ ƒŕōḿ {{author}}",
"reconnectGithub": "Ŕēćōńńēćţ ĜĩţĤũƀ",
+165
View File
@@ -0,0 +1,165 @@
---
spec: docs/specs/ui/pr-task-status-summary.md
created: 2026-08-06
status: implemented
---
# Implementation Plan: PR Task Status Summary
## Overview
Replace the shared task PR icon's pipe-delimited tooltip body with a small, localized summary
component. The component will keep existing status derivation and icon attributes authoritative,
then present each PR as a header plus separate review, CI, and merge/state rows. Focused component
tests and desktop/mobile Playwright coverage will prove readability without changing task-row
behavior or introducing a new touch surface.
## Frontend
### Structured PR summary
- Add `apps/web/components/github/pr-task-status-summary.tsx` as the presentation boundary for
tooltip content. Its pure derivation accepts each `TaskPR` plus the already-derived
ready-to-merge boolean, builds only rows backed by available source fields, and feeds one grouped
rendered entry per PR.
- Separate `#<number>` and the PR title in a compact header. Use a fixed preferred width with a
viewport-safe maximum, `text-pretty`, `break-words`, and a readable `leading-snug` treatment so a
long title wraps without turning the disclosure into one dense paragraph.
- Render review, CI, and merge/terminal state in a two-column description layout. Each row has a
translated category label, humanized known status, Tabler semantic icon, and restrained status
tone. Reuse the status-copy and icon language established by
`apps/web/components/github/my-github/pr-status-badges.tsx`; preserve unknown non-empty values as
provider data rather than translating or omitting them.
- Use the existing `TooltipContent` surface, shadow, collision handling, and arrow. Use dividers
only to separate multiple PR entries; do not add nested cards, animation, or interactive content.
- Add stable selectors for the summary, each PR entry, title, and status rows so tests can assert
hierarchy rather than matching one concatenated string.
### Shared task PR icon integration
- In `apps/web/components/github/pr-task-icon.tsx`, replace both single- and multi-PR tooltip bodies
with `PRTaskStatusSummary`. Pass `isPRReadyToMerge(pr)` into the presentation component rather
than reimplementing merge readiness.
- Remove `getPRTooltip` and its string-concatenation tests. Keep `getPRStatusColor`,
`aggregatePRStatusColor`, `areAllOpenPRsReadyToMerge`, `pickDefaultPR`, and all existing
`data-pr-*` attributes unchanged.
- Give the informational trigger a localized accessible name and keyboard focus path while
preserving its passive behavior and the containing task row's existing activation semantics.
The shared integration intentionally updates sidebar, Kanban-card, and rich-list uses together.
### Localization
- Add only missing summary labels and known-state values to
`apps/web/src/locales/en/github.json`, resolving them with `useTranslation()` during render.
- Regenerate `apps/web/src/locales/pseudo/github.json`; do not call `t()` at module scope or
translate raw provider fallback values.
## Tests
- **What:** available fields become separate review, CI, and merge/state rows; ready-to-merge uses
the supplied strict result; missing fields produce no blank rows; unknown values remain visible.
**File:** `apps/web/components/github/pr-task-status-summary.test.ts`.
**How:** exercise the exported pure summary derivation with table-driven single-PR states for
approved/success/ready, failure and conflicts, draft/terminal cases, missing values, and an
unknown provider value. Rendered hierarchy, wrapping, icons, and selectors remain Playwright
assertions rather than component-test implementation details.
- **What:** the shared icon still guards malformed store entries, retains single/multi attributes,
and mounts one grouped summary entry per linked PR.
**Files:** `apps/web/components/github/pr-task-icon.render.test.tsx` and
`apps/web/components/github/pr-task-icon.test.ts`.
**How:** preserve the existing malformed-store and attribute cases, and replace the obsolete
`getPRTooltip` string cases while leaving status-derivation coverage intact.
## E2E Tests
- **Scenario:** a ready PR with a long title exposes separate **Review — Approved**, **CI —
Passed**, and **Merge — Ready to merge** rows from the sidebar icon.
**File:** `apps/web/e2e/tests/pr/pr-status-badge.spec.ts`.
**What to verify:** locate the icon inside the named sidebar task row, hover it, scope through the
open Tooltip portal, assert the structured selectors and text, and confirm the summary and
document remain within the viewport. Keep existing ready-to-merge attribute assertions.
- **Scenario:** a linked-PR row in the phone task-switcher remains a passive indicator inside the
existing primary task-navigation row.
**File:** `apps/web/e2e/tests/task/mobile-task-status-summary.spec.ts`.
**What to verify:** retain the PR-state and horizontal-overflow assertions, tap the row, and
confirm normal task navigation without a new task-row overlay.
## Mobile Parity
- **Desktop outcome:** scan PR identity, review, CI, and merge state directly from the task icon.
- **Mobile entry point:** the task row remains the primary tap target; after navigation, the
existing `PRStatusChipDrawer` remains the detailed touch surface.
- **Nearest shipped exemplars:** `session-task-switcher-sheet.tsx` supplies task-row navigation and
scroll ownership; `pr-status-chip.tsx` supplies the existing coarse-pointer PR detail drawer.
- **Presentation choice and rationale:** this is a content-only desktop tooltip refinement. A new
adjacent drawer trigger would need a 44 px hitbox and would compete with the dense row's primary
navigation for supplemental information already available in the task view.
- **Geometry and state:** the mobile drawer keeps its existing single internal scroll owner,
safe-area behavior, task data, selection, and row action logic. No responsive preference or
domain state is duplicated.
- **Proof:** the focused `mobile-chrome` task-status-summary scenario verifies the shared icon,
row navigation, and absence of document horizontal overflow.
## Verification Results
Completed on 2026-08-07.
- RED unit proof: the new summary assertion failed against the old pipe-delimited string, receiving
`PR #2966: ... | Review: approved | CI: success | Ready to merge` instead of structured rows.
- RED browser proof: the focused Chromium test failed because `pr-task-status-summary` did not
exist in the old tooltip.
- Focused frontend suite: 4 files and 78 tests passed, covering existing icon semantics plus ready,
failure/conflict, blocked, draft, terminal, missing, and unknown summary states.
- Desktop Playwright: 1 Chromium scenario passed for hover hierarchy, long-title wrapping,
viewport containment, keyboard focus, and two-PR grouping.
- Mobile Playwright: 1 `mobile-chrome` scenario passed for passive-icon row navigation and
document overflow containment.
- `pnpm run typecheck`, targeted ESLint, `i18n:check`, and `i18n:ratchet` passed. The i18n check
retained 918 advisory pre-existing `zh-cn` catalog parity issues and confirmed the pseudo locale
is synchronized.
- A gated PR screenshot was captured and visually inspected at
`apps/web/.pr-assets/pr-status-badge--readable-task-pr-summary.png`; the following normal E2E run
cleaned the ignored `.pr-assets` directory before successful runner teardown.
No blockers or residual in-scope risks remain.
## Implementation Waves And Parallel Candidates
Wave 1 (sequential):
- [x] [Task 01: Build readable PR task summary](task-01-readable-pr-task-summary.md)
## Verification
```bash
cd apps && pnpm install --frozen-lockfile
cd apps && pnpm --filter @kandev/web test -- --run \
components/github/pr-task-icon.test.ts \
components/github/pr-task-icon-draft.test.ts \
components/github/pr-task-icon.render.test.tsx \
components/github/pr-task-status-summary.test.ts
cd apps/web && pnpm e2e:run tests/pr/pr-status-badge.spec.ts \
-- --grep "renders readable task PR summary"
cd apps/web && pnpm e2e:run --project mobile-chrome \
tests/task/mobile-task-status-summary.spec.ts
cd apps/web && pnpm run typecheck
cd apps && pnpm --filter @kandev/web run i18n:check
cd apps && pnpm --filter @kandev/web run i18n:ratchet
```
## Risks
- `PRTaskIcon` is shared by sidebar, Kanban, and rich task-list surfaces. The summary must improve
all three without changing click propagation or the compact trigger geometry.
- Radix can render both an accessibility tooltip copy and a visible portal. Browser assertions
must start at the open `[data-slot="tooltip-content"]` portal to avoid strict-locator ambiguity.
- Humanizing provider values must not create a second merge-readiness rule. Only
`isPRReadyToMerge` may produce **Ready to merge**, and unknown non-empty values must survive.
- Multiple linked PRs increase tooltip height. Keep each entry compact and avoid adding full CI or
review detail that belongs in the existing PR status popover/drawer.
## Documentation Impact
No public documentation change is required. This work changes an in-product informational
disclosure, not a command, setting, workflow, API, or public term. No ADR is warranted because it
adds no architectural boundary or persistent contract.
@@ -0,0 +1,148 @@
---
id: "01-readable-pr-task-summary"
title: "Build readable PR task summary"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/ui/pr-task-status-summary.md"
---
# Task 01: Build Readable PR Task Summary
## Acceptance
- Fine-pointer hover and keyboard focus show each linked PR as a distinct header plus labelled,
icon-supported review, CI, and merge/state rows; long titles wrap and missing rows stay absent.
- Existing icon color, multi-PR count, merge-readiness logic, `data-pr-*` attributes, task-row
activation, and coarse-pointer behavior remain unchanged; all new copy is localized.
- Focused component tests, desktop hover E2E, mobile task-row E2E, typecheck, and i18n checks pass.
## TDD Sequence
1. RED: add the summary component cases for ready, failure/conflict, draft/terminal, missing,
unknown, long-title, and multi-PR states through the pure summary derivation; update icon tests
and observe the new behavior expectations fail against the current pipe-delimited tooltip.
2. RED: extend the desktop PR badge spec with the sidebar hover hierarchy/geometry assertions and
the mobile task-status spec with row navigation; run both focused specs before production
changes.
3. GREEN: add `PRTaskStatusSummary`, wire both icon variants to it, add accessible trigger copy,
and add English catalog keys. Reuse existing readiness helpers and status tones.
4. REFACTOR: remove `getPRTooltip`, keep the presentation component focused, generate the pseudo
locale, inspect desktop and phone rendered results, then run every exact verification command.
## Verification
```bash
cd apps && pnpm install --frozen-lockfile
cd apps && pnpm --filter @kandev/web test -- --run \
components/github/pr-task-icon.test.ts \
components/github/pr-task-icon-draft.test.ts \
components/github/pr-task-icon.render.test.tsx \
components/github/pr-task-status-summary.test.ts
cd apps/web && pnpm e2e:run tests/pr/pr-status-badge.spec.ts \
-- --grep "renders readable task PR summary"
cd apps/web && pnpm e2e:run --project mobile-chrome \
tests/task/mobile-task-status-summary.spec.ts
cd apps/web && pnpm run typecheck
cd apps && pnpm --filter @kandev/web run i18n:check
cd apps && pnpm --filter @kandev/web run i18n:ratchet
```
## Files likely touched
- `apps/web/components/github/pr-task-icon.tsx`
- `apps/web/components/github/pr-task-icon-draft.test.ts`
- `apps/web/components/github/pr-task-icon.test.ts`
- `apps/web/components/github/pr-task-icon.render.test.tsx`
- `apps/web/components/github/pr-task-status-summary.tsx`
- `apps/web/components/github/pr-task-status-summary.test.ts`
- `apps/web/src/locales/en/github.json`
- `apps/web/src/locales/pseudo/github.json`
- `apps/web/e2e/tests/pr/pr-status-badge.spec.ts`
- `apps/web/e2e/tests/task/mobile-task-status-summary.spec.ts`
- `docs/specs/ui/pr-task-status-summary.md`
- `docs/specs/INDEX.md`
- `docs/plans/pr-task-status-summary/plan.md`
- `docs/plans/pr-task-status-summary/task-01-readable-pr-task-summary.md`
## Dependencies
None. Current `TaskPR` projections and readiness/color helpers contain every required field and
remain authoritative.
## Parallelism
`sequential` — summary presentation, shared icon wiring, locale keys, and focused tests describe
one small vertical slice and overlap on the same component contract.
## Inputs
- `docs/specs/ui/pr-task-status-summary.md`, especially status-preservation, multi-PR, fallback,
and mobile scenarios.
- `docs/plans/pr-task-status-summary/plan.md`.
- Existing derivation and trigger attributes in `apps/web/components/github/pr-task-icon.tsx`.
- Status icon/copy precedent in
`apps/web/components/github/my-github/pr-status-badges.tsx`.
- Tooltip portal selector guidance in `.agents/skills/e2e/SKILL.md`.
- Content-only mobile guidance and exemplars in `.agents/skills/mobile-parity/SKILL.md`.
- Typography and surface rules in `/home/zeval/.agents/skills/make-interfaces-feel-better/`.
## Risks
- Never infer **Ready to merge** from aggregate check counts or raw `mergeable_state`; use the
existing strict helper result passed by `PRTaskIcon`.
- Keep unknown provider values as data and resolve known catalog keys only during render.
- Scope E2E assertions to the visible open Tooltip portal because Radix may mount a second
accessibility copy.
- Keep the compact indicator passive and preserve parent row click/keyboard behavior on every
shared surface.
## Results
RED evidence:
- `pnpm --filter @kandev/web test -- --run
components/github/pr-task-status-summary.test.ts` failed as expected: the old implementation
returned one pipe-delimited string instead of `{ number, title, rows }`.
- `pnpm e2e:run tests/pr/pr-status-badge.spec.ts -- --grep "renders readable task PR summary"`
failed as expected because the old tooltip had no structured summary selector.
GREEN evidence:
- `pnpm install --frozen-lockfile` completed from `apps/` without changing the lockfile.
- The focused Vitest command above, expanded to include `pr-task-icon-draft.test.ts`, passed 78 tests
across 4 files. A final summary-only run passed 6 tests after adding explicit failure/conflict
coverage.
- The desktop E2E command passed 1 Chromium test. It proves hover and keyboard focus, readable
labelled rows, long-title wrapping, viewport containment, and two linked PR groups.
- The mobile E2E command passed 1 `mobile-chrome` test. `.tap()` on the task row navigated normally
and the document remained free of horizontal overflow.
- `pnpm run typecheck`, the task's targeted ESLint invocation, `i18n:check`, and `i18n:ratchet` all
passed. Pseudo-locale generation produced 30 namespaces / 5,740 messages; the 918 reported
`zh-cn` parity issues are advisory and pre-existing.
- `CAPTURE_PR_ASSETS=1` produced
`apps/web/.pr-assets/pr-status-badge--readable-task-pr-summary.png` for visual inspection. The
subsequent standard E2E cleanup removed the ignored capture and every E2E run completed teardown.
Files changed:
- `apps/web/components/github/pr-task-icon.tsx`
- `apps/web/components/github/pr-task-icon-draft.test.ts`
- `apps/web/components/github/pr-task-icon.test.ts`
- `apps/web/components/github/pr-task-status-summary.tsx`
- `apps/web/components/github/pr-task-status-summary.test.ts`
- `apps/web/src/locales/en/github.json`
- `apps/web/src/locales/pseudo/github.json`
- `apps/web/e2e/tests/pr/pr-status-badge.spec.ts`
- `apps/web/e2e/tests/task/mobile-task-status-summary.spec.ts`
- This spec, plan, task, and `docs/specs/INDEX.md`.
No blockers or residual in-scope risks remain. Existing ready-to-merge, aggregate color, task-row
activation, and coarse-pointer detail ownership are preserved.
## Output contract
Report implementation outcome, files changed, status semantics preserved, localization keys,
desktop/mobile rendered evidence, exact tests and counts, blockers or risks, and synchronized
task/plan status. Present visual changes in a concise **Before / After** table.
+1
View File
@@ -154,6 +154,7 @@ Subscription quota tracking and per-agent cheap-model profile routing.
|---|---|
| [ci-pr-automation](ui/ci-pr-automation.md) | building |
| [github-pr-review-actions](ui/github-pr-review-actions.md) | shipped |
| [pr-task-status-summary](ui/pr-task-status-summary.md) | shipped |
| [comment-markdown](ui/comment-markdown.md) | shipped |
| [resizable-markdown-tables](ui/resizable-markdown-tables.md) | building |
| [transcript-auto-scroll](ui/transcript-auto-scroll.md) | building |
+77
View File
@@ -0,0 +1,77 @@
---
status: shipped
created: 2026-08-06
owner: kandev
---
# PR Task Status Summary
## Why
Task pull-request indicators currently flatten the PR title, review state, CI state, and
mergeability into one pipe-delimited sentence. Long titles and several adjacent status values
make the hover disclosure slow to scan during a brief pointer interaction.
## What
- Hovering a task's PR indicator with a fine pointer, or focusing it with the keyboard, shows a
compact structured summary instead of a pipe-delimited sentence.
- Each linked PR has a distinct entry with its PR number and title in a visual header. Long titles
wrap within the summary rather than widening it beyond the viewport.
- Review, CI, and merge or terminal state appear as separate labelled rows when their source data
is available. Each row combines readable text with a semantic icon and color; no meaning depends
on color or icon alone.
- Known GitHub states use concise user-facing copy such as **Approved**, **Passed**, **In
progress**, **Changes requested**, **Conflicts**, and **Ready to merge**. An unrecognized
non-empty provider value remains visible instead of being dropped.
- Ready-to-merge copy uses the existing strict `isPRReadyToMerge` rule. Draft, terminal,
review, check, mergeability, aggregate icon color, and multi-PR attention precedence do not
change.
- A task with several linked PRs shows one consistently structured entry per PR, separated clearly,
while retaining the existing aggregate icon color, PR count, and ready-to-merge attributes.
- The disclosure uses localized copy, readable line height, restrained semantic colors, and a
viewport-contained width. It remains informational: opening it does not navigate, mutate PR
state, fetch new detail, or change task-row activation.
- The shared task PR indicator uses the same summary in the desktop sidebar, Kanban cards, and rich
task-list rows.
- On coarse-pointer phone and tablet layouts, task rows keep their existing primary tap behavior
and passive PR indicator. Detailed touch interaction remains available through the existing PR
status drawer after opening the task, so this visual refinement adds no hover-only required
action or competing compact touch target.
## Scenarios
- **GIVEN** an open PR with an approval, successful CI, and clean mergeability, **WHEN** the user
hovers or focuses its task PR indicator, **THEN** the summary separates the PR number and title
from labelled **Review — Approved**, **CI — Passed**, and **Merge — Ready to merge** rows.
- **GIVEN** a PR with changes requested, failing CI, a merge conflict, a blocked state, or a
behind-base state, **WHEN** its summary opens, **THEN** each available condition appears in its
own readable row with matching semantic text and icon without changing the indicator's existing
attention color.
- **GIVEN** a long PR title, **WHEN** its summary opens near a viewport edge, **THEN** the title
wraps inside the collision-aware summary and the disclosure causes no document-level horizontal
overflow.
- **GIVEN** a pointer-hovered PR indicator also has keyboard focus, **WHEN** the pointer leaves,
**THEN** the summary remains visible until focus leaves or the user dismisses it with Escape.
- **GIVEN** several PRs linked to one task, **WHEN** the task PR summary opens, **THEN** every PR is
identifiable by number and title and its available status rows are visually grouped beneath it.
- **GIVEN** a non-empty provider status Kandev does not recognize, **WHEN** the summary opens,
**THEN** that value remains visible as fallback text; absent status fields do not produce empty
rows.
- **GIVEN** a phone task-switcher drawer, **WHEN** a linked-PR task row renders and the user taps
the row, **THEN** the existing task navigation and PR indicator remain usable without horizontal
overflow or a new hover-dependent interaction.
## Out of scope
- Changing GitHub status derivation, merge readiness, polling, API contracts, persistence, or
task-to-PR associations.
- Adding check-run lists, reviewer lists, comments, merge controls, or other full PR-detail content
to the task indicator summary.
- Changing GitLab merge-request or Azure DevOps pull-request indicators.
- Adding a new phone/tablet task-row drawer or turning the compact PR indicator into a separate
touch action.
## Implementation plan
[PR task status summary plan](../../plans/pr-task-status-summary/plan.md)